[Glitch] Change design of lists in web UI
Port 62603508c7
to glitch-soc
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
pull/2903/head
parent
2259949e9b
commit
217e0f87fd
|
@ -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'])));
|
||||
};
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { apiCreate, apiUpdate } from 'flavours/glitch/api/lists';
|
||||
import type { List } from 'flavours/glitch/models/list';
|
||||
import { createDataLoadingThunk } from 'flavours/glitch/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),
|
||||
);
|
|
@ -68,6 +68,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
method: Method,
|
||||
url: string,
|
||||
args: {
|
||||
signal?: AbortSignal;
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
apiRequestPost,
|
||||
apiRequestPut,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
} from 'flavours/glitch/api';
|
||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import type { ApiListJSON } from 'flavours/glitch/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],
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 || 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,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) {
|
||||
dispatch(isUserTouching() ? openModal({
|
||||
|
|
|
@ -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));
|
|
@ -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 'flavours/glitch/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));
|
|
@ -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));
|
|
@ -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 'flavours/glitch/actions/lists';
|
||||
import { createList } from 'flavours/glitch/actions/lists_typed';
|
||||
import {
|
||||
apiGetAccountLists,
|
||||
apiAddAccountToList,
|
||||
apiRemoveAccountFromList,
|
||||
} from 'flavours/glitch/api/lists';
|
||||
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import { CheckBox } from 'flavours/glitch/components/check_box';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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;
|
|
@ -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));
|
|
@ -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));
|
|
@ -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 'flavours/glitch/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));
|
|
@ -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));
|
|
@ -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 'flavours/glitch/actions/columns';
|
||||
import { fetchList, updateList } from 'flavours/glitch/actions/lists';
|
||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { connectListStream } from 'flavours/glitch/actions/streaming';
|
||||
import { expandListTimeline } from 'flavours/glitch/actions/timelines';
|
||||
|
@ -23,17 +21,10 @@ import Column from 'flavours/glitch/components/column';
|
|||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
|
||||
import { WithRouterPropTypes } from 'flavours/glitch/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));
|
||||
|
|
|
@ -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 'flavours/glitch/actions/lists';
|
||||
import { Button } from 'flavours/glitch/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));
|
|
@ -1,91 +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 'flavours/glitch/actions/lists';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import Column from 'flavours/glitch/features/ui/components/column';
|
||||
import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
|
||||
import ColumnSubheading from 'flavours/glitch/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} icon='bars' iconComponent={ListAltIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
|
||||
<NewListForm />
|
||||
|
||||
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||
<ScrollableList
|
||||
scrollKey='lists'
|
||||
emptyMessage={emptyMessage}
|
||||
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));
|
|
@ -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 'flavours/glitch/actions/lists';
|
||||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||
import { getOrderedLists } from 'flavours/glitch/selectors/lists';
|
||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/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;
|
|
@ -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 'flavours/glitch/actions/accounts';
|
||||
import { importFetchedAccounts } from 'flavours/glitch/actions/importer';
|
||||
import { fetchList } from 'flavours/glitch/actions/lists';
|
||||
import { apiRequest } from 'flavours/glitch/api';
|
||||
import {
|
||||
apiGetAccounts,
|
||||
apiAddAccountToList,
|
||||
apiRemoveAccountFromList,
|
||||
} from 'flavours/glitch/api/lists';
|
||||
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
|
||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||
import { Button } from 'flavours/glitch/components/button';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { FollowersCounter } from 'flavours/glitch/components/counters';
|
||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
||||
import { ButtonInTabsBar } from 'flavours/glitch/features/ui/util/columns_context';
|
||||
import { me } from 'flavours/glitch/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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;
|
|
@ -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 'flavours/glitch/actions/lists';
|
||||
import { createList, updateList } from 'flavours/glitch/actions/lists_typed';
|
||||
import { apiGetAccounts } from 'flavours/glitch/api/lists';
|
||||
import type { RepliesPolicyType } from 'flavours/glitch/api_types/lists';
|
||||
import Column from 'flavours/glitch/components/column';
|
||||
import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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;
|
|
@ -11,7 +11,6 @@ import {
|
|||
ReportModal,
|
||||
SettingsModal,
|
||||
EmbedModal,
|
||||
ListEditor,
|
||||
ListAdder,
|
||||
CompareHistoryModal,
|
||||
FilterModal,
|
||||
|
@ -72,7 +71,6 @@ export const MODAL_COMPONENTS = {
|
|||
'DEPRECATED_SETTINGS': () => Promise.resolve({ default: DeprecatedSettingsModal }),
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
'LIST_ADDER': ListAdder,
|
||||
'COMPARE_HISTORY': CompareHistoryModal,
|
||||
|
|
|
@ -62,11 +62,13 @@ import {
|
|||
FollowedTags,
|
||||
LinkTimeline,
|
||||
ListTimeline,
|
||||
Lists,
|
||||
ListEdit,
|
||||
ListMembers,
|
||||
Blocks,
|
||||
DomainBlocks,
|
||||
Mutes,
|
||||
PinnedStatuses,
|
||||
Lists,
|
||||
GettingStartedMisc,
|
||||
Directory,
|
||||
Explore,
|
||||
|
@ -216,6 +218,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 />
|
||||
|
|
|
@ -158,10 +158,6 @@ export function EmbedModal () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'../components/embed_modal');
|
||||
}
|
||||
|
||||
export function ListEditor () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'../../list_editor');
|
||||
}
|
||||
|
||||
export function ListAdder () {
|
||||
return import(/* webpackChunkName: "features/glitch/async/list_adder" */'../../list_adder');
|
||||
}
|
||||
|
@ -229,3 +225,11 @@ export function LinkTimeline () {
|
|||
export function AnnualReportModal () {
|
||||
return import(/*webpackChunkName: "flavours/glitch/async/modals/annual_report_modal" */'../components/annual_report_modal');
|
||||
}
|
||||
|
||||
export function ListEdit () {
|
||||
return import(/*webpackChunkName: "flavours/glitch/async/lists" */'../../lists/new');
|
||||
}
|
||||
|
||||
export function ListMembers () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/lists" */'../../lists/members');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import type { RecordOf } from 'immutable';
|
||||
import { Record } from 'immutable';
|
||||
|
||||
import type { ApiListJSON } from 'flavours/glitch/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);
|
||||
}
|
|
@ -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 local_settings from './local_settings';
|
||||
import { markersReducer } from './markers';
|
||||
import media_attachments from './media_attachments';
|
||||
|
@ -71,9 +69,7 @@ const reducers = {
|
|||
notificationGroups: notificationGroupsReducer,
|
||||
height_cache,
|
||||
custom_emojis,
|
||||
lists,
|
||||
listEditor,
|
||||
listAdder,
|
||||
lists: listsReducer,
|
||||
filters,
|
||||
conversations,
|
||||
suggestions,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import type { Reducer } from '@reduxjs/toolkit';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { createList, updateList } from 'flavours/glitch/actions/lists_typed';
|
||||
import type { ApiListJSON } from 'flavours/glitch/api_types/lists';
|
||||
import { createList as createListFromJSON } from 'flavours/glitch/models/list';
|
||||
import type { List } from 'flavours/glitch/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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import type { List } from 'flavours/glitch/models/list';
|
||||
import type { RootState } from 'flavours/glitch/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(),
|
||||
);
|
|
@ -630,16 +630,6 @@ body,
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.simple_form {
|
||||
.actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-form-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
@ -4991,6 +4991,7 @@ a.status-card {
|
|||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
|
@ -5408,7 +5409,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;
|
||||
|
@ -5430,6 +5432,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);
|
||||
|
@ -6233,7 +6246,7 @@ a.status-card {
|
|||
|
||||
.modal-root {
|
||||
position: relative;
|
||||
z-index: 9999;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
.modal-root__overlay {
|
||||
|
@ -6838,12 +6851,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;
|
||||
|
@ -8521,92 +8536,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;
|
||||
|
@ -10677,7 +10606,7 @@ noscript {
|
|||
position: fixed;
|
||||
bottom: 2rem;
|
||||
inset-inline-start: 0;
|
||||
z-index: 999;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
@ -11686,3 +11615,87 @@ noscript {
|
|||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
@ -1256,6 +1256,8 @@ code {
|
|||
}
|
||||
|
||||
.app-form {
|
||||
padding: 20px;
|
||||
|
||||
&__avatar-input,
|
||||
&__header-input {
|
||||
display: block;
|
||||
|
@ -1371,4 +1373,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)};
|
||||
}
|
||||
|
|
|
@ -125,4 +125,5 @@ $dismiss-overlay-width: 4rem;
|
|||
--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};
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue