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<List>) => apiCreate(list),
+);
+
+export const updateList = createDataLoadingThunk(
+  'list/update',
+  (list: Partial<List>) => 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<ApiResponse = unknown>(
   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<ApiListJSON>) =>
+  apiRequestPost<ApiListJSON>('v1/lists', list);
+
+export const apiUpdate = (list: Partial<ApiListJSON>) =>
+  apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
+
+export const apiGetAccounts = (listId: string) =>
+  apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
+    limit: 0,
+  });
+
+export const apiGetAccountLists = (accountId: string) =>
+  apiRequestGet<ApiListJSON[]>(`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<HTMLInputElement>) => void;
-  label: React.ReactNode;
+  checked?: boolean;
+  indeterminate?: boolean;
+  name?: string;
+  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
+  label?: React.ReactNode;
 }
 
 export const CheckBox: React.FC<Props> = ({
@@ -30,6 +30,7 @@ export const CheckBox: React.FC<Props> = ({
         value={value}
         checked={checked}
         onChange={onChange}
+        readOnly={!onChange}
       />
 
       <span
@@ -42,7 +43,7 @@ export const CheckBox: React.FC<Props> = ({
         )}
       </span>
 
-      <span>{label}</span>
+      {label && <span>{label}</span>}
     </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 {
           <div className='scrollable__append'>
             <LoadingIndicator />
           </div>
+
+          {footer}
         </div>
       );
     } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
       scrollableArea = (
-        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
+        <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
           <div role='feed' className='item-list'>
             {prepend}
 
@@ -375,6 +378,8 @@ class ScrollableList extends PureComponent {
 
             {!hasMore && append}
           </div>
+
+          {footer}
         </div>
       );
     } else {
@@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
           <div className='empty-column-indicator'>
             {emptyMessage}
           </div>
+
+          {footer}
         </div>
       );
     }
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 (
-      <div className='account'>
-        <div className='account__wrapper'>
-          <div className='account__display-name'>
-            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
-            <DisplayName account={account} />
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-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 = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
-    } else {
-      button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
-    }
-
-    return (
-      <div className='list'>
-        <div className='list__wrapper'>
-          <div className='list__display-name'>
-            <Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
-            {list.get('title')}
-          </div>
-
-          <div className='account__relationship'>
-            {button}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-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 (
-      <div className='modal-root__modal list-adder'>
-        <div className='list-adder__account'>
-          <Account accountId={accountId} />
-        </div>
-
-        <NewListForm />
-
-
-        <div className='list-adder__lists'>
-          {listIds.map(ListId => <List key={ListId} listId={ListId} />)}
-        </div>
-      </div>
-    );
-  }
-
-}
-
-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<HTMLInputElement>) => {
+      onChange(id, e.target.checked);
+    },
+    [id, onChange],
+  );
+
+  return (
+    // eslint-disable-next-line jsx-a11y/label-has-associated-control
+    <label className='lists__item'>
+      <div className='lists__item__title'>
+        <Icon id='list-ul' icon={ListAltIcon} />
+        <span>{title}</span>
+      </div>
+
+      <CheckBox value={id} checked={checked} onChange={handleChange} />
+    </label>
+  );
+};
+
+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<HTMLInputElement>) => {
+      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 (
+    <form className='lists__item' onSubmit={handleSubmit}>
+      <label className='lists__item__title'>
+        <Icon id='list-ul' icon={ListAltIcon} />
+
+        <input
+          type='text'
+          value={title}
+          onChange={handleChange}
+          maxLength={30}
+          required
+          placeholder={intl.formatMessage(messages.newList)}
+        />
+      </label>
+
+      <Button text={intl.formatMessage(messages.createList)} type='submit' />
+    </form>
+  );
+};
+
+const ListAdder: React.FC<{
+  accountId: string;
+  onClose: () => void;
+}> = ({ accountId, onClose }) => {
+  const intl = useIntl();
+  const dispatch = useAppDispatch();
+  const account = useAppSelector((state) => state.accounts.get(accountId));
+  const lists = useAppSelector((state) => getOrderedLists(state));
+  const [listIds, setListIds] = useState<string[]>([]);
+
+  useEffect(() => {
+    dispatch(fetchLists());
+
+    apiGetAccountLists(accountId)
+      .then((data) => {
+        setListIds(data.map((l) => l.id));
+        return '';
+      })
+      .catch(() => {
+        // Nothing
+      });
+  }, [dispatch, setListIds, accountId]);
+
+  const handleToggle = useCallback(
+    (listId: string, checked: boolean) => {
+      if (checked) {
+        setListIds((currentListIds) => [listId, ...currentListIds]);
+
+        apiAddAccountToList(listId, accountId).catch(() => {
+          setListIds((currentListIds) =>
+            currentListIds.filter((id) => id !== listId),
+          );
+        });
+      } else {
+        setListIds((currentListIds) =>
+          currentListIds.filter((id) => id !== listId),
+        );
+
+        apiRemoveAccountFromList(listId, accountId).catch(() => {
+          setListIds((currentListIds) => [listId, ...currentListIds]);
+        });
+      }
+    },
+    [setListIds, accountId],
+  );
+
+  const handleCreate = useCallback(
+    (list: ApiListJSON) => {
+      setListIds((currentListIds) => [list.id, ...currentListIds]);
+
+      apiAddAccountToList(list.id, accountId).catch(() => {
+        setListIds((currentListIds) =>
+          currentListIds.filter((id) => id !== list.id),
+        );
+      });
+    },
+    [setListIds, accountId],
+  );
+
+  return (
+    <div className='modal-root__modal dialog-modal'>
+      <div className='dialog-modal__header'>
+        <IconButton
+          className='dialog-modal__header__close'
+          title={intl.formatMessage(messages.close)}
+          icon='times'
+          iconComponent={CloseIcon}
+          onClick={onClose}
+        />
+
+        <span className='dialog-modal__header__title'>
+          <FormattedMessage
+            id='lists.add_to_lists'
+            defaultMessage='Add {name} to lists'
+            values={{ name: <strong>@{account?.acct}</strong> }}
+          />
+        </span>
+      </div>
+
+      <div className='dialog-modal__content'>
+        <div className='lists-scrollable'>
+          <NewListItem onCreate={handleCreate} />
+
+          {lists.map((list) => (
+            <ListItem
+              key={list.id}
+              id={list.id}
+              title={list.title}
+              checked={listIds.includes(list.id)}
+              onChange={handleToggle}
+            />
+          ))}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default ListAdder;
diff --git a/app/javascript/mastodon/features/list_editor/components/account.jsx b/app/javascript/mastodon/features/list_editor/components/account.jsx
deleted file mode 100644
index 77d32af80a..0000000000
--- a/app/javascript/mastodon/features/list_editor/components/account.jsx
+++ /dev/null
@@ -1,82 +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 { removeFromListEditor, addToListEditor } from '../../../actions/lists';
-import { Avatar } from '../../../components/avatar';
-import { DisplayName } from '../../../components/display_name';
-import { IconButton } from '../../../components/icon_button';
-import { makeGetAccount } from '../../../selectors';
-
-const messages = defineMessages({
-  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
-  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
-});
-
-const makeMapStateToProps = () => {
-  const getAccount = makeGetAccount();
-
-  const mapStateToProps = (state, { accountId, added }) => ({
-    account: getAccount(state, accountId),
-    added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = (dispatch, { accountId }) => ({
-  onRemove: () => dispatch(removeFromListEditor(accountId)),
-  onAdd: () => dispatch(addToListEditor(accountId)),
-});
-
-class Account extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
-    intl: PropTypes.object.isRequired,
-    onRemove: PropTypes.func.isRequired,
-    onAdd: PropTypes.func.isRequired,
-    added: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    added: false,
-  };
-
-  render () {
-    const { account, intl, onRemove, onAdd, added } = this.props;
-
-    let button;
-
-    if (added) {
-      button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
-    } else {
-      button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
-    }
-
-    return (
-      <div className='account'>
-        <div className='account__wrapper'>
-          <div className='account__display-name'>
-            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
-            <DisplayName account={account} />
-          </div>
-
-          <div className='account__relationship'>
-            {button}
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));
diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx b/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx
deleted file mode 100644
index 89f596636e..0000000000
--- a/app/javascript/mastodon/features/list_editor/components/edit_list_form.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import { connect } from 'react-redux';
-
-import CheckIcon from '@/material-icons/400-24px/check.svg?react';
-
-import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
-import { IconButton } from '../../../components/icon_button';
-
-const messages = defineMessages({
-  title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
-});
-
-const mapStateToProps = state => ({
-  value: state.getIn(['listEditor', 'title']),
-  disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onChange: value => dispatch(changeListEditorTitle(value)),
-  onSubmit: () => dispatch(submitListEditor(false)),
-});
-
-class ListForm 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 title = intl.formatMessage(messages.title);
-
-    return (
-      <form className='column-inline-form' onSubmit={this.handleSubmit}>
-        <input
-          className='setting-text'
-          value={value}
-          onChange={this.handleChange}
-        />
-
-        <IconButton
-          disabled={disabled}
-          icon='check'
-          iconComponent={CheckIcon}
-          title={title}
-          onClick={this.handleClick}
-        />
-      </form>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListForm));
diff --git a/app/javascript/mastodon/features/list_editor/components/search.jsx b/app/javascript/mastodon/features/list_editor/components/search.jsx
deleted file mode 100644
index 097d4f3f41..0000000000
--- a/app/javascript/mastodon/features/list_editor/components/search.jsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import classNames from 'classnames';
-
-import { connect } from 'react-redux';
-
-import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
-import SearchIcon from '@/material-icons/400-24px/search.svg?react';
-import { Icon }  from 'mastodon/components/icon';
-
-import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
-
-const messages = defineMessages({
-  search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
-});
-
-const mapStateToProps = state => ({
-  value: state.getIn(['listEditor', 'suggestions', 'value']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onSubmit: value => dispatch(fetchListSuggestions(value)),
-  onClear: () => dispatch(clearListSuggestions()),
-  onChange: value => dispatch(changeListSuggestions(value)),
-});
-
-class Search extends 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);
-    }
-  };
-
-  handleClear = () => {
-    this.props.onClear();
-  };
-
-  render () {
-    const { value, intl } = this.props;
-    const hasValue = value.length > 0;
-
-    return (
-      <div className='list-editor__search search'>
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
-
-          <input
-            className='search__input'
-            type='text'
-            value={value}
-            onChange={this.handleChange}
-            onKeyUp={this.handleKeyUp}
-            placeholder={intl.formatMessage(messages.search)}
-          />
-        </label>
-
-        <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
-          <Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
-          <Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));
diff --git a/app/javascript/mastodon/features/list_editor/index.jsx b/app/javascript/mastodon/features/list_editor/index.jsx
deleted file mode 100644
index 85e90169e8..0000000000
--- a/app/javascript/mastodon/features/list_editor/index.jsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { injectIntl } from 'react-intl';
-
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import spring from 'react-motion/lib/spring';
-
-import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
-import Motion from '../ui/util/optional_motion';
-
-import Account from './components/account';
-import EditListForm from './components/edit_list_form';
-import Search from './components/search';
-
-const mapStateToProps = state => ({
-  accountIds: state.getIn(['listEditor', 'accounts', 'items']),
-  searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onInitialize: listId => dispatch(setupListEditor(listId)),
-  onClear: () => dispatch(clearListSuggestions()),
-  onReset: () => dispatch(resetListEditor()),
-});
-
-class ListEditor extends ImmutablePureComponent {
-
-  static propTypes = {
-    listId: PropTypes.string.isRequired,
-    onClose: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-    onInitialize: PropTypes.func.isRequired,
-    onClear: PropTypes.func.isRequired,
-    onReset: PropTypes.func.isRequired,
-    accountIds: ImmutablePropTypes.list.isRequired,
-    searchAccountIds: ImmutablePropTypes.list.isRequired,
-  };
-
-  componentDidMount () {
-    const { onInitialize, listId } = this.props;
-    onInitialize(listId);
-  }
-
-  componentWillUnmount () {
-    const { onReset } = this.props;
-    onReset();
-  }
-
-  render () {
-    const { accountIds, searchAccountIds, onClear } = this.props;
-    const showSearch = searchAccountIds.size > 0;
-
-    return (
-      <div className='modal-root__modal list-editor'>
-        <EditListForm />
-
-        <Search />
-
-        <div className='drawer__pager'>
-          <div className='drawer__inner list-editor__accounts'>
-            {accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
-          </div>
-
-          {showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
-
-          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
-            {({ x }) => (
-              <div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
-                {searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
-              </div>
-            )}
-          </Motion>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));
diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx
index 59d9f86977..9020fd2bee 100644
--- a/app/javascript/mastodon/features/list_timeline/index.jsx
+++ b/app/javascript/mastodon/features/list_timeline/index.jsx
@@ -1,21 +1,19 @@
 import PropTypes from 'prop-types';
 import { PureComponent } from 'react';
 
-import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 
 import { Helmet } from 'react-helmet';
-import { withRouter } from 'react-router-dom';
+import { Link, withRouter } from 'react-router-dom';
 
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 
-import Toggle from 'react-toggle';
-
 import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
 import EditIcon from '@/material-icons/400-24px/edit.svg?react';
 import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
 import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
-import { fetchList, updateList } from 'mastodon/actions/lists';
+import { fetchList } from 'mastodon/actions/lists';
 import { openModal } from 'mastodon/actions/modal';
 import { connectListStream } from 'mastodon/actions/streaming';
 import { expandListTimeline } from 'mastodon/actions/timelines';
@@ -23,17 +21,10 @@ import Column from 'mastodon/components/column';
 import ColumnHeader from 'mastodon/components/column_header';
 import { Icon }  from 'mastodon/components/icon';
 import { LoadingIndicator } from 'mastodon/components/loading_indicator';
-import { RadioButton } from 'mastodon/components/radio_button';
 import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
 import { WithRouterPropTypes } from 'mastodon/utils/react_router';
 
-const messages = defineMessages({
-  followed:   { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
-  none:    { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
-  list:  { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
-});
-
 const mapStateToProps = (state, props) => ({
   list: state.getIn(['lists', props.params.id]),
   hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
@@ -115,13 +106,6 @@ class ListTimeline extends PureComponent {
     this.props.dispatch(expandListTimeline(id, { maxId }));
   };
 
-  handleEditClick = () => {
-    this.props.dispatch(openModal({
-      modalType: 'LIST_EDITOR',
-      modalProps: { listId: this.props.params.id },
-    }));
-  };
-
   handleDeleteClick = () => {
     const { dispatch, columnId } = this.props;
     const { id } = this.props.params;
@@ -129,25 +113,11 @@ class ListTimeline extends PureComponent {
     dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
   };
 
-  handleRepliesPolicyChange = ({ target }) => {
-    const { dispatch } = this.props;
-    const { id } = this.props.params;
-    dispatch(updateList(id, undefined, false, undefined, target.value));
-  };
-
-  onExclusiveToggle = ({ target }) => {
-    const { dispatch } = this.props;
-    const { id } = this.props.params;
-    dispatch(updateList(id, undefined, false, target.checked, undefined));
-  };
-
   render () {
-    const { hasUnread, columnId, multiColumn, list, intl } = this.props;
+    const { hasUnread, columnId, multiColumn, list } = this.props;
     const { id } = this.props.params;
     const pinned = !!columnId;
     const title  = list ? list.get('title') : id;
-    const replies_policy = list ? list.get('replies_policy') : undefined;
-    const isExclusive = list ? list.get('exclusive') : undefined;
 
     if (typeof list === 'undefined') {
       return (
@@ -178,35 +148,14 @@ class ListTimeline extends PureComponent {
         >
           <div className='column-settings'>
             <section className='column-header__links'>
-              <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
+              <Link to={`/lists/${id}/edit`} className='text-btn column-header__setting-btn'>
                 <Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
-              </button>
+              </Link>
 
               <button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
                 <Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
               </button>
             </section>
-
-            <section>
-              <div className='setting-toggle'>
-                <Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
-                <label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
-                  <FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
-                </label>
-              </div>
-            </section>
-
-            {replies_policy !== undefined && (
-              <section aria-labelledby={`list-${id}-replies-policy`}>
-                <h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
-
-                <div className='column-settings__row'>
-                  { ['none', 'list', 'followed'].map(policy => (
-                    <RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
-                  ))}
-                </div>
-              </section>
-            )}
           </div>
         </ColumnHeader>
 
@@ -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 (
-      <form className='column-inline-form' onSubmit={this.handleSubmit}>
-        <label>
-          <span style={{ display: 'none' }}>{label}</span>
-
-          <input
-            className='setting-text'
-            value={value}
-            disabled={disabled}
-            onChange={this.handleChange}
-            placeholder={label}
-          />
-        </label>
-
-        <Button
-          disabled={disabled || !value}
-          text={title}
-          onClick={this.handleClick}
-        />
-      </form>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));
diff --git a/app/javascript/mastodon/features/lists/index.jsx b/app/javascript/mastodon/features/lists/index.jsx
deleted file mode 100644
index a7648f55b2..0000000000
--- a/app/javascript/mastodon/features/lists/index.jsx
+++ /dev/null
@@ -1,94 +0,0 @@
-import PropTypes from 'prop-types';
-
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-
-import { Helmet } from 'react-helmet';
-
-import { createSelector } from '@reduxjs/toolkit';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { connect } from 'react-redux';
-
-import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
-import { fetchLists } from 'mastodon/actions/lists';
-import Column from 'mastodon/components/column';
-import ColumnHeader from 'mastodon/components/column_header';
-import { LoadingIndicator } from 'mastodon/components/loading_indicator';
-import ScrollableList from 'mastodon/components/scrollable_list';
-import ColumnLink from 'mastodon/features/ui/components/column_link';
-import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
-
-import NewListForm from './components/new_list_form';
-
-const messages = defineMessages({
-  heading: { id: 'column.lists', defaultMessage: 'Lists' },
-  subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
-});
-
-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),
-});
-
-class Lists extends ImmutablePureComponent {
-
-  static propTypes = {
-    params: PropTypes.object.isRequired,
-    dispatch: PropTypes.func.isRequired,
-    lists: ImmutablePropTypes.list,
-    intl: PropTypes.object.isRequired,
-    multiColumn: PropTypes.bool,
-  };
-
-  UNSAFE_componentWillMount () {
-    this.props.dispatch(fetchLists());
-  }
-
-  render () {
-    const { intl, lists, multiColumn } = this.props;
-
-    if (!lists) {
-      return (
-        <Column>
-          <LoadingIndicator />
-        </Column>
-      );
-    }
-
-    const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
-
-    return (
-      <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
-        <ColumnHeader title={intl.formatMessage(messages.heading)} icon='list-ul' iconComponent={ListAltIcon} multiColumn={multiColumn} />
-
-        <NewListForm />
-
-        <ScrollableList
-          scrollKey='lists'
-          emptyMessage={emptyMessage}
-          prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
-          bindToDocument={!multiColumn}
-        >
-          {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')} />,
-          )}
-        </ScrollableList>
-
-        <Helmet>
-          <title>{intl.formatMessage(messages.heading)}</title>
-          <meta name='robots' content='noindex' />
-        </Helmet>
-      </Column>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps)(injectIntl(Lists));
diff --git a/app/javascript/mastodon/features/lists/index.tsx b/app/javascript/mastodon/features/lists/index.tsx
new file mode 100644
index 0000000000..cf413a1fe0
--- /dev/null
+++ b/app/javascript/mastodon/features/lists/index.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useMemo, useCallback } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+
+import AddIcon from '@/material-icons/400-24px/add.svg?react';
+import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
+import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
+import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
+import { fetchLists } from 'mastodon/actions/lists';
+import { openModal } from 'mastodon/actions/modal';
+import Column from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import { Icon } from 'mastodon/components/icon';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import { getOrderedLists } from 'mastodon/selectors/lists';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+const messages = defineMessages({
+  heading: { id: 'column.lists', defaultMessage: 'Lists' },
+  create: { id: 'lists.create_list', defaultMessage: 'Create list' },
+  edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
+  delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
+  more: { id: 'status.more', defaultMessage: 'More' },
+});
+
+const ListItem: React.FC<{
+  id: string;
+  title: string;
+}> = ({ id, title }) => {
+  const dispatch = useAppDispatch();
+  const intl = useIntl();
+
+  const handleDeleteClick = useCallback(() => {
+    dispatch(
+      openModal({
+        modalType: 'CONFIRM_DELETE_LIST',
+        modalProps: {
+          listId: id,
+        },
+      }),
+    );
+  }, [dispatch, id]);
+
+  const menu = useMemo(
+    () => [
+      { text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
+      { text: intl.formatMessage(messages.delete), action: handleDeleteClick },
+    ],
+    [intl, id, handleDeleteClick],
+  );
+
+  return (
+    <div className='lists__item'>
+      <Link to={`/lists/${id}`} className='lists__item__title'>
+        <Icon id='list-ul' icon={ListAltIcon} />
+        <span>{title}</span>
+      </Link>
+
+      <DropdownMenuContainer
+        scrollKey='lists'
+        items={menu}
+        icons='ellipsis-h'
+        iconComponent={MoreHorizIcon}
+        direction='right'
+        title={intl.formatMessage(messages.more)}
+      />
+    </div>
+  );
+};
+
+const Lists: React.FC<{
+  multiColumn?: boolean;
+}> = ({ multiColumn }) => {
+  const dispatch = useAppDispatch();
+  const intl = useIntl();
+  const lists = useAppSelector((state) => getOrderedLists(state));
+
+  useEffect(() => {
+    dispatch(fetchLists());
+  }, [dispatch]);
+
+  const emptyMessage = (
+    <>
+      <span>
+        <FormattedMessage
+          id='lists.no_lists_yet'
+          defaultMessage='No lists yet.'
+        />
+        <br />
+        <FormattedMessage
+          id='lists.create_a_list_to_organize'
+          defaultMessage='Create a new list to organize your Home feed'
+        />
+      </span>
+
+      <SquigglyArrow className='empty-column-indicator__arrow' />
+    </>
+  );
+
+  return (
+    <Column
+      bindToDocument={!multiColumn}
+      label={intl.formatMessage(messages.heading)}
+    >
+      <ColumnHeader
+        title={intl.formatMessage(messages.heading)}
+        icon='list-ul'
+        iconComponent={ListAltIcon}
+        multiColumn={multiColumn}
+        extraButton={
+          <Link
+            to='/lists/new'
+            className='column-header__button'
+            title={intl.formatMessage(messages.create)}
+            aria-label={intl.formatMessage(messages.create)}
+          >
+            <Icon id='plus' icon={AddIcon} />
+          </Link>
+        }
+      />
+
+      <ScrollableList
+        scrollKey='lists'
+        emptyMessage={emptyMessage}
+        bindToDocument={!multiColumn}
+      >
+        {lists.map((list) => (
+          <ListItem key={list.id} id={list.id} title={list.title} />
+        ))}
+      </ScrollableList>
+
+      <Helmet>
+        <title>{intl.formatMessage(messages.heading)}</title>
+        <meta name='robots' content='noindex' />
+      </Helmet>
+    </Column>
+  );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default Lists;
diff --git a/app/javascript/mastodon/features/lists/members.tsx b/app/javascript/mastodon/features/lists/members.tsx
new file mode 100644
index 0000000000..bdd5064ce4
--- /dev/null
+++ b/app/javascript/mastodon/features/lists/members.tsx
@@ -0,0 +1,373 @@
+import { useCallback, useState, useEffect, useRef } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+import { useParams, Link } from 'react-router-dom';
+
+import { useDebouncedCallback } from 'use-debounce';
+
+import AddIcon from '@/material-icons/400-24px/add.svg?react';
+import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
+import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
+import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
+import { fetchFollowing } from 'mastodon/actions/accounts';
+import { importFetchedAccounts } from 'mastodon/actions/importer';
+import { fetchList } from 'mastodon/actions/lists';
+import { apiRequest } from 'mastodon/api';
+import {
+  apiGetAccounts,
+  apiAddAccountToList,
+  apiRemoveAccountFromList,
+} from 'mastodon/api/lists';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+import { Avatar } from 'mastodon/components/avatar';
+import { Button } from 'mastodon/components/button';
+import Column from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import { FollowersCounter } from 'mastodon/components/counters';
+import { DisplayName } from 'mastodon/components/display_name';
+import { Icon } from 'mastodon/components/icon';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import { ShortNumber } from 'mastodon/components/short_number';
+import { VerifiedBadge } from 'mastodon/components/verified_badge';
+import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
+import { me } from 'mastodon/initial_state';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+const messages = defineMessages({
+  heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
+  placeholder: {
+    id: 'lists.search_placeholder',
+    defaultMessage: 'Search people you follow',
+  },
+  enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
+  add: { id: 'lists.add_member', defaultMessage: 'Add' },
+  remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
+  back: { id: 'column_back_button.label', defaultMessage: 'Back' },
+});
+
+type Mode = 'remove' | 'add';
+
+const ColumnSearchHeader: React.FC<{
+  onBack: () => void;
+  onSubmit: (value: string) => void;
+}> = ({ onBack, onSubmit }) => {
+  const intl = useIntl();
+  const [value, setValue] = useState('');
+
+  const handleChange = useCallback(
+    ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
+      setValue(value);
+      onSubmit(value);
+    },
+    [setValue, onSubmit],
+  );
+
+  const handleSubmit = useCallback(() => {
+    onSubmit(value);
+  }, [onSubmit, value]);
+
+  return (
+    <ButtonInTabsBar>
+      <form className='column-search-header' onSubmit={handleSubmit}>
+        <button
+          type='button'
+          className='column-header__back-button compact'
+          onClick={onBack}
+          aria-label={intl.formatMessage(messages.back)}
+        >
+          <Icon
+            id='chevron-left'
+            icon={ArrowBackIcon}
+            className='column-back-button__icon'
+          />
+        </button>
+
+        <input
+          type='search'
+          value={value}
+          onChange={handleChange}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          /* eslint-disable-next-line jsx-a11y/no-autofocus */
+          autoFocus
+        />
+      </form>
+    </ButtonInTabsBar>
+  );
+};
+
+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 (
+    <div className='account'>
+      <div className='account__wrapper'>
+        <Link
+          key={account.id}
+          className='account__display-name'
+          title={account.acct}
+          to={`/@${account.acct}`}
+          data-hover-card-account={account.id}
+        >
+          <div className='account__avatar-wrapper'>
+            <Avatar account={account} size={36} />
+          </div>
+
+          <div className='account__contents'>
+            <DisplayName account={account} />
+
+            <div className='account__details'>
+              <ShortNumber
+                value={account.followers_count}
+                renderer={FollowersCounter}
+              />{' '}
+              {firstVerifiedField && (
+                <VerifiedBadge link={firstVerifiedField.value} />
+              )}
+            </div>
+          </div>
+        </Link>
+
+        <div className='account__relationship'>
+          <Button
+            text={intl.formatMessage(
+              partOfList ? messages.remove : messages.add,
+            )}
+            onClick={handleClick}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+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<string[]>([]);
+  const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [mode, setMode] = useState<Mode>('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<AbortController | null>(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<ApiAccountJSON[]>('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 (
+    <Column
+      bindToDocument={!multiColumn}
+      label={intl.formatMessage(messages.heading)}
+    >
+      {mode === 'remove' ? (
+        <ColumnHeader
+          title={intl.formatMessage(messages.heading)}
+          icon='list-ul'
+          iconComponent={ListAltIcon}
+          multiColumn={multiColumn}
+          showBackButton
+          extraButton={
+            <button
+              onClick={handleSearchClick}
+              type='button'
+              className='column-header__button'
+              title={intl.formatMessage(messages.enterSearch)}
+              aria-label={intl.formatMessage(messages.enterSearch)}
+            >
+              <Icon id='plus' icon={AddIcon} />
+            </button>
+          }
+        />
+      ) : (
+        <ColumnSearchHeader
+          onBack={handleDismissSearchClick}
+          onSubmit={handleSearch}
+        />
+      )}
+
+      <ScrollableList
+        scrollKey='list_members'
+        trackScroll={!multiColumn}
+        bindToDocument={!multiColumn}
+        isLoading={loading}
+        showLoading={loading && displayedAccountIds.length === 0}
+        hasMore={false}
+        footer={
+          mode === 'remove' && (
+            <>
+              <div className='spacer' />
+
+              <div className='column-footer'>
+                <Link to={`/lists/${id}`} className='button button--block'>
+                  <FormattedMessage id='lists.done' defaultMessage='Done' />
+                </Link>
+              </div>
+            </>
+          )
+        }
+        emptyMessage={
+          mode === 'remove' ? (
+            <>
+              <span>
+                <FormattedMessage
+                  id='lists.no_members_yet'
+                  defaultMessage='No members yet.'
+                />
+                <br />
+                <FormattedMessage
+                  id='lists.find_users_to_add'
+                  defaultMessage='Find users to add'
+                />
+              </span>
+
+              <SquigglyArrow className='empty-column-indicator__arrow' />
+            </>
+          ) : (
+            <FormattedMessage
+              id='lists.no_results_found'
+              defaultMessage='No results found.'
+            />
+          )
+        }
+      >
+        {displayedAccountIds.map((accountId) => (
+          <AccountItem
+            key={accountId}
+            accountId={accountId}
+            listId={id}
+            partOfList={
+              displayedAccountIds === accountIds ||
+              accountIds.includes(accountId)
+            }
+            onToggle={handleAccountToggle}
+          />
+        ))}
+      </ScrollableList>
+
+      <Helmet>
+        <title>{intl.formatMessage(messages.heading)}</title>
+        <meta name='robots' content='noindex' />
+      </Helmet>
+    </Column>
+  );
+};
+
+// 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<string[]>([]);
+
+  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 (
+    <Link to={`/lists/${id}/members`} className='app-form__link'>
+      <div className='app-form__link__text'>
+        <strong>
+          <FormattedMessage
+            id='lists.list_members'
+            defaultMessage='List members'
+          />
+        </strong>
+        <FormattedMessage
+          id='lists.list_members_count'
+          defaultMessage='{count, plural, one {# member} other {# members}}'
+          values={{ count }}
+        />
+      </div>
+
+      <div className='avatar-pile'>
+        {avatars.map((url) => (
+          <img key={url} src={url} alt='' />
+        ))}
+      </div>
+    </Link>
+  );
+};
+
+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<RepliesPolicyType>('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<HTMLInputElement>) => {
+      setTitle(value);
+    },
+    [setTitle],
+  );
+
+  const handleExclusiveChange = useCallback(
+    ({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
+      setExclusive(checked);
+    },
+    [setExclusive],
+  );
+
+  const handleRepliesPolicyChange = useCallback(
+    ({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
+      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 (
+    <Column
+      bindToDocument={!multiColumn}
+      label={intl.formatMessage(id ? messages.edit : messages.create)}
+    >
+      <ColumnHeader
+        title={intl.formatMessage(id ? messages.edit : messages.create)}
+        icon='list-ul'
+        iconComponent={ListAltIcon}
+        multiColumn={multiColumn}
+        showBackButton
+      />
+
+      <div className='scrollable'>
+        <form className='simple_form app-form' onSubmit={handleSubmit}>
+          <div className='fields-group'>
+            <div className='input with_label'>
+              <div className='label_input'>
+                <label htmlFor='list_title'>
+                  <FormattedMessage
+                    id='lists.list_name'
+                    defaultMessage='List name'
+                  />
+                </label>
+
+                <div className='label_input__wrapper'>
+                  <input
+                    id='list_title'
+                    type='text'
+                    value={title}
+                    onChange={handleTitleChange}
+                    maxLength={30}
+                    required
+                    placeholder=' '
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className='fields-group'>
+            <div className='input with_label'>
+              <div className='label_input'>
+                <label htmlFor='list_replies_policy'>
+                  <FormattedMessage
+                    id='lists.show_replies_to'
+                    defaultMessage='Include replies from list members to'
+                  />
+                </label>
+
+                <div className='label_input__wrapper'>
+                  <select
+                    id='list_replies_policy'
+                    value={repliesPolicy}
+                    onChange={handleRepliesPolicyChange}
+                  >
+                    <FormattedMessage
+                      id='lists.replies_policy.none'
+                      defaultMessage='No one'
+                    >
+                      {(msg) => <option value='none'>{msg}</option>}
+                    </FormattedMessage>
+                    <FormattedMessage
+                      id='lists.replies_policy.list'
+                      defaultMessage='Members of the list'
+                    >
+                      {(msg) => <option value='list'>{msg}</option>}
+                    </FormattedMessage>
+                    <FormattedMessage
+                      id='lists.replies_policy.followed'
+                      defaultMessage='Any followed user'
+                    >
+                      {(msg) => <option value='followed'>{msg}</option>}
+                    </FormattedMessage>
+                  </select>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          {id && (
+            <div className='fields-group'>
+              <MembersLink id={id} />
+            </div>
+          )}
+
+          <div className='fields-group'>
+            {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+            <label className='app-form__toggle'>
+              <div className='app-form__toggle__label'>
+                <strong>
+                  <FormattedMessage
+                    id='lists.exclusive'
+                    defaultMessage='Hide members in Home'
+                  />
+                </strong>
+                <span className='hint'>
+                  <FormattedMessage
+                    id='lists.exclusive_hint'
+                    defaultMessage='If someone is on this list, hide them in your Home feed to avoid seeing their posts twice.'
+                  />
+                </span>
+              </div>
+
+              <div className='app-form__toggle__toggle'>
+                <div>
+                  <Toggle
+                    checked={exclusive}
+                    onChange={handleExclusiveChange}
+                  />
+                </div>
+              </div>
+            </label>
+          </div>
+
+          <div className='actions'>
+            <button className='button' type='submit'>
+              {submitting ? (
+                <LoadingIndicator />
+              ) : id ? (
+                <FormattedMessage id='lists.save' defaultMessage='Save' />
+              ) : (
+                <FormattedMessage id='lists.create' defaultMessage='Create' />
+              )}
+            </button>
+          </div>
+        </form>
+      </div>
+
+      <Helmet>
+        <title>
+          {intl.formatMessage(id ? messages.edit : messages.create)}
+        </title>
+        <meta name='robots' content='noindex' />
+      </Helmet>
+    </Column>
+  );
+};
+
+// 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 {
             <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
             <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
             <WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
+            <WrappedRoute path='/lists/new' component={ListEdit} content={children} />
+            <WrappedRoute path='/lists/:id/edit' component={ListEdit} content={children} />
+            <WrappedRoute path='/lists/:id/members' component={ListMembers} content={children} />
             <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
             <WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
             <WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
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<ApiListJSON>; // no changes from server shape
+export type List = RecordOf<ListShape>;
+
+const ListFactory = Record<ListShape>({
+  id: '',
+  title: '',
+  exclusive: false,
+  replies_policy: 'list',
+});
+
+export function createList(attributes: Partial<ListShape>) {
+  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<string, List | null>();
+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> = (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<string, List | null>) =>
+    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 @@
+<svg width="109" height="294" viewBox="0 0 109 294" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.76026 291.019C0.942479 291.152 0.386871 291.922 0.519274 292.74C0.651676 293.558 1.42195 294.113 2.23973 293.981L1.76026 291.019ZM47.5 148.5L48.5174 147.398L47.5 148.5ZM18.0001 156.5L16.5001 156.5L18.0001 156.5ZM56.4644 187L55.9468 185.592L56.4644 187ZM95.7394 0.194935C95.0187 -0.213447 94.1033 0.0398367 93.6949 0.760613L87.0401 12.5064C86.6318 13.2272 86.885 14.1425 87.6058 14.5509C88.3266 14.9593 89.242 14.706 89.6503 13.9852L95.5657 3.54453L106.006 9.45991C106.727 9.86826 107.643 9.61501 108.051 8.89423C108.459 8.17345 108.206 7.25808 107.485 6.84973L95.7394 0.194935ZM2.23973 293.981C15.8924 291.77 27.1878 282.838 36.0256 270.568C44.8661 258.294 51.3453 242.555 55.3614 226.415C59.3785 210.271 60.9539 193.636 59.9213 179.528C58.8957 165.516 55.2699 153.631 48.5174 147.398L46.4826 149.602C52.3545 155.022 55.9159 165.901 56.9293 179.747C57.9356 193.495 56.4016 209.81 52.4502 225.69C48.4977 241.575 42.1489 256.934 33.5913 268.815C25.0308 280.7 14.3576 288.98 1.76026 291.019L2.23973 293.981ZM48.5174 147.398C41.8156 141.211 33.9683 138.272 27.5716 139.593C24.335 140.262 21.5235 142.02 19.5438 144.91C17.5787 147.779 16.5001 151.661 16.5001 156.5L19.5001 156.5C19.5001 152.089 20.4839 148.846 22.0188 146.606C23.5391 144.386 25.6651 143.051 28.1784 142.531C33.2817 141.478 40.1844 143.789 46.4826 149.602L48.5174 147.398ZM16.5001 156.5C16.5001 166.744 21.6708 176.498 29.2488 182.798C36.8394 189.109 47.0071 192.075 56.982 188.408L55.9468 185.592C47.1899 188.812 38.1254 186.277 31.1668 180.492C24.1956 174.696 19.5001 165.755 19.5001 156.5L16.5001 156.5ZM56.982 188.408C68.8996 184.026 77.8374 172.201 84.4321 156.822C91.0496 141.389 95.442 122.069 98.0809 102.183C103.353 62.4546 101.68 20.0182 96.4457 1.10004L93.5543 1.90001C98.6367 20.2692 100.353 62.2528 95.107 101.788C92.4868 121.533 88.143 140.555 81.6749 155.64C75.184 170.777 66.6866 181.644 55.9468 185.592L56.982 188.408Z" fill="currentColor"/>
+</svg>