1
0
Fork 0
forked from gitea/nas

Merge remote-tracking branch 'parent/main' into upstream-20241126

This commit is contained in:
KMY 2024-11-26 12:56:31 +09:00
commit 8a075ba4c6
303 changed files with 7495 additions and 4498 deletions

View file

@ -1,96 +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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromAntennaAdder, addToAntennaAdder, removeExcludeFromAntennaAdder, addExcludeToAntennaAdder } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
});
const MapStateToProps = (state, { antennaId, added }) => ({
antenna: state.get('antennas').get(antennaId),
added: typeof added === 'undefined' ? state.getIn(['antennaAdder', 'antennas', 'items']).includes(antennaId) : added,
});
const mapDispatchToProps = (dispatch, { antennaId }) => ({
onRemove: () => dispatch(removeFromAntennaAdder(antennaId)),
onAdd: () => dispatch(addToAntennaAdder(antennaId)),
onExcludeRemove: () => dispatch(removeExcludeFromAntennaAdder(antennaId)),
onExcludeAdd: () => dispatch(addExcludeToAntennaAdder(antennaId)),
});
class Antenna extends ImmutablePureComponent {
static propTypes = {
antenna: ImmutablePropTypes.map.isRequired,
isExclude: PropTypes.bool.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
onExcludeRemove: PropTypes.func.isRequired,
onExcludeAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
handleRemove = () => {
if (this.props.isExclude) {
this.props.onExcludeRemove();
} else {
this.props.onRemove();
}
};
handleAdd = () => {
if (this.props.isExclude) {
this.props.onExcludeAdd();
} else {
this.props.onAdd();
}
};
render () {
const { antenna, intl, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={this.handleRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={this.handleAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='wifi' icon={AntennaIcon} className='column-link__icon' fixedWidth />
{antenna.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(Antenna));

View file

@ -1,84 +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 { setupAntennaAdder, resetAntennaAdder, setupExcludeAntennaAdder } from '../../actions/antennas';
import NewAntennaForm from '../antennas/components/new_antenna_form';
import Account from '../list_adder/components/account';
import Antenna from './components/antenna';
// hack
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennaIds: getOrderedAntennas(state).map(antenna=>antenna.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupAntennaAdder(accountId)),
onExcludeInitialize: accountId => dispatch(setupExcludeAntennaAdder(accountId)),
onReset: () => dispatch(resetAntennaAdder()),
});
class AntennaAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
isExclude: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onExcludeInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
antennaIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { isExclude, onInitialize, onExcludeInitialize, accountId } = this.props;
if (isExclude) {
onExcludeInitialize(accountId);
} else {
onInitialize(accountId);
}
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, antennaIds, isExclude } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewAntennaForm />
<div className='list-adder__lists'>
{antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} isExclude={isExclude} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaAdder));

View file

@ -0,0 +1,227 @@
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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { createAntenna } from 'mastodon/actions/antennas_typed';
import {
apiGetAccountAntennas,
apiAddAccountToAntenna,
apiAddExcludeAccountToAntenna,
apiRemoveAccountFromAntenna,
apiRemoveExcludeAccountFromAntenna,
} from 'mastodon/api/antennas';
import type { ApiAntennaJSON } from 'mastodon/api_types/antennas';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newAntenna: {
id: 'antennas.new_antenna_name',
defaultMessage: 'New antenna name',
},
createAntenna: {
id: 'antennas.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const AntennaItem: 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='antennas__item'>
<div className='antennas__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewAntennaItem: React.FC<{
onCreate: (antenna: ApiAntennaJSON) => 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(createAntenna({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='antennas__item' onSubmit={handleSubmit}>
<label className='antennas__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newAntenna)}
/>
</label>
<Button text={intl.formatMessage(messages.createAntenna)} type='submit' />
</form>
);
};
const AntennaAdder: React.FC<{
accountId: string;
isExclude: boolean;
onClose: () => void;
}> = ({ accountId, isExclude, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const antennas = useAppSelector((state) => getOrderedAntennas(state));
const [antennaIds, setAntennaIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchAntennas());
apiGetAccountAntennas(accountId)
.then((data) => {
setAntennaIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setAntennaIds, accountId]);
const handleToggle = useCallback(
(antennaId: string, checked: boolean) => {
if (checked) {
setAntennaIds((currentAntennaIds) => [antennaId, ...currentAntennaIds]);
const func = isExclude
? apiAddExcludeAccountToAntenna
: apiAddAccountToAntenna;
func(antennaId, accountId).catch(() => {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antennaId),
);
});
} else {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antennaId),
);
const func = isExclude
? apiRemoveExcludeAccountFromAntenna
: apiRemoveAccountFromAntenna;
func(antennaId, accountId).catch(() => {
setAntennaIds((currentAntennaIds) => [
antennaId,
...currentAntennaIds,
]);
});
}
},
[setAntennaIds, accountId, isExclude],
);
const handleCreate = useCallback(
(antenna: ApiAntennaJSON) => {
setAntennaIds((currentAntennaIds) => [antenna.id, ...currentAntennaIds]);
apiAddAccountToAntenna(antenna.id, accountId).catch(() => {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antenna.id),
);
});
},
[setAntennaIds, 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='antennas.add_to_antennas'
defaultMessage='Add {name} to antennas'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='antennas-scrollable'>
<NewAntennaItem onCreate={handleCreate} />
{antennas.map((antenna) => (
<AntennaItem
key={antenna.id}
id={antenna.id}
title={antenna.title}
checked={antennaIds.includes(antenna.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AntennaAdder;

View file

@ -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 { changeAntennaEditorTitle, submitAntennaEditor } from 'mastodon/actions/antennas';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'antennas.new.title_placeholder', defaultMessage: 'New antenna title' },
title: { id: 'antennas.new.create', defaultMessage: 'Add antenna' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'title']),
disabled: state.getIn(['antennaEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeAntennaEditorTitle(value)),
onSubmit: () => dispatch(submitAntennaEditor(true)),
});
class NewAntennaForm 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(NewAntennaForm));

View file

@ -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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowing } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
placeholder: {
id: 'lists.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
add: { id: 'lists.add_member', defaultMessage: 'Add' },
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
listId: string;
partOfList: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, listId, partOfList, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfList) {
void apiRemoveAccountFromList(listId, accountId);
} else {
void apiAddAccountToList(listId, accountId);
}
onToggle(accountId);
}, [accountId, listId, partOfList, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const ListMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
);
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchList(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfList = accountIds.includes(accountId);
if (partOfList) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={AntennaIcon}
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' && (
<>
{displayedAccountIds.length > 0 && <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;

View file

@ -1,97 +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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewAntennaForm from './components/new_antenna_form';
const messages = defineMessages({
heading: { id: 'column.antennas', defaultMessage: 'Antennas' },
subheading: { id: 'antennas.subheading', defaultMessage: 'Your antennas' },
insert_list: { id: 'antennas.insert_list', defaultMessage: 'List' },
insert_home: { id: 'antennas.insert_home', defaultMessage: 'Home' },
});
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennas: getOrderedAntennas(state),
});
class Antennas extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
antennas: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchAntennas());
}
render () {
const { intl, antennas, multiColumn } = this.props;
if (!antennas) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.antennas' defaultMessage="You don't have any antennas yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='wifi' iconComponent={AntennaIcon} multiColumn={multiColumn} />
<NewAntennaForm />
<ScrollableList
scrollKey='antennas'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{antennas.map(antenna => (
<ColumnLink key={antenna.get('id')} to={`/antennast/${antenna.get('id')}`} icon='wifi' iconComponent={AntennaIcon} text={antenna.get('title')}
badge={antenna.get('insert_feeds') ? intl.formatMessage(antenna.get('list') ? messages.insert_list : messages.insert_home) : undefined} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Antennas));

View file

@ -0,0 +1,160 @@
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 MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.antennas', defaultMessage: 'Antennas' },
create: { id: 'antennas.create_antenna', defaultMessage: 'Create antenna' },
edit: { id: 'antennas.edit', defaultMessage: 'Edit antenna' },
delete: { id: 'antennas.delete', defaultMessage: 'Delete antenna' },
more: { id: 'status.more', defaultMessage: 'More' },
insert_list: { id: 'antennas.insert_list', defaultMessage: 'List' },
insert_home: { id: 'antennas.insert_home', defaultMessage: 'Home' },
});
const AntennaItem: React.FC<{
id: string;
title: string;
insert_feeds: boolean;
isList: boolean;
}> = ({ id, title, insert_feeds, isList }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
antennaId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/antennas/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='antennas__item'>
<Link to={`/antennas/${id}`} className='antennas__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<span>{title}</span>
{insert_feeds
? intl.formatMessage(
isList ? messages.insert_list : messages.insert_home,
)
: undefined}
</Link>
<DropdownMenuContainer
scrollKey='antennas'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Antennas: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const antennas = useAppSelector((state) => getOrderedAntennas(state));
useEffect(() => {
dispatch(fetchAntennas());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='antennas.no_antennas_yet'
defaultMessage='No antennas yet.'
/>
<br />
<FormattedMessage
id='antennas.create_a_antenna_to_organize'
defaultMessage='Create a new antenna 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='antenna-ul'
iconComponent={AntennaIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/antennas/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='antennas'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{antennas.map((antenna) => (
<AntennaItem
key={antenna.id}
id={antenna.id}
title={antenna.title}
insert_feeds={antenna.insert_feeds}
isList={!!antenna.list}
/>
))}
</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 Antennas;

View file

@ -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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowing } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
placeholder: {
id: 'lists.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
add: { id: 'lists.add_member', defaultMessage: 'Add' },
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
listId: string;
partOfList: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, listId, partOfList, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfList) {
void apiRemoveAccountFromList(listId, accountId);
} else {
void apiAddAccountToList(listId, accountId);
}
onToggle(accountId);
}, [accountId, listId, partOfList, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const ListMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
);
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchList(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfList = accountIds.includes(accountId);
if (partOfList) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={AntennaIcon}
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' && (
<>
{displayedAccountIds.length > 0 && <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;

View file

@ -0,0 +1,336 @@
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 AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntenna } from 'mastodon/actions/antennas';
import { createAntenna, updateAntenna } from 'mastodon/actions/antennas_typed';
import { fetchLists } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_antenna', defaultMessage: 'Edit antenna' },
create: { id: 'column.create_antenna', defaultMessage: 'Create antenna' },
});
const FiltersLink: React.FC<{
id: string;
}> = ({ id }) => {
return (
<Link to={`/antennasw/${id}`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='antennas.filter_items'
defaultMessage='Antenna filtering'
/>
</strong>
</div>
</Link>
);
};
const NewAntenna: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const antenna = useAppSelector((state) =>
id ? state.antennas.get(id) : undefined,
);
const lists = useAppSelector((state) => state.lists);
const [title, setTitle] = useState('');
const [stl, setStl] = useState(false);
const [ltl, setLtl] = useState(false);
const [insertFeeds, setInsertFeeds] = useState(false);
const [listId, setListId] = useState('');
const [withMediaOnly, setWithMediaOnly] = useState(false);
const [ignoreReblog, setIgnoreReblog] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchAntenna(id));
dispatch(fetchLists());
}
}, [dispatch, id]);
useEffect(() => {
if (id && antenna) {
setTitle(antenna.title);
setStl(antenna.stl);
setLtl(antenna.ltl);
setInsertFeeds(antenna.insert_feeds);
setListId(antenna.list?.id ?? '');
setWithMediaOnly(antenna.with_media_only);
setIgnoreReblog(antenna.ignore_reblog);
}
}, [
setTitle,
setStl,
setLtl,
setInsertFeeds,
setListId,
setWithMediaOnly,
setIgnoreReblog,
id,
antenna,
lists,
]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
/*
const handleStlChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setStl(checked);
},
[setStl],
);
const handleLtlChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setLtl(checked);
},
[setLtl],
);
*/
const handleInsertFeedsChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setInsertFeeds(checked);
},
[setInsertFeeds],
);
const handleListIdChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setListId(value);
},
[setListId],
);
/*
const handleWithMediaOnlyChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setWithMediaOnly(checked);
},
[setWithMediaOnly],
);
const handleIgnoreReblogChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setIgnoreReblog(checked);
},
[setIgnoreReblog],
);
*/
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateAntenna({
id,
title,
stl,
ltl,
insert_feeds: insertFeeds,
list_id: listId,
with_media_only: withMediaOnly,
ignore_reblog: ignoreReblog,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createAntenna({
title,
stl,
ltl,
insert_feeds: insertFeeds,
list_id: listId,
with_media_only: withMediaOnly,
ignore_reblog: ignoreReblog,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/antennas/${result.payload.id}/edit`);
history.push(`/antennas/${result.payload.id}/members`);
}
return '';
});
}
}, [
history,
dispatch,
setSubmitting,
id,
title,
stl,
ltl,
insertFeeds,
listId,
withMediaOnly,
ignoreReblog,
]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='antenna-ul'
iconComponent={AntennaIcon}
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='antenna_title'>
<FormattedMessage
id='antennas.antenna_name'
defaultMessage='Antenna name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='antenna_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</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='antennas.insert_feeds'
defaultMessage='Insert to feeds'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='antennas.insert_feeds_hint'
defaultMessage='Insert to any timelines.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={insertFeeds}
onChange={handleInsertFeedsChange}
/>
</div>
</div>
</label>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='antenna_list'>
<FormattedMessage
id='antennas.insert_list'
defaultMessage='List'
/>
</label>
<div className='label_input__wrapper'>
<select
id='antenna_insert_list'
value={listId}
onChange={handleListIdChange}
>
<option value=''>Home</option>
{lists.forEach(
(list) =>
list !== null && (
<option key={list.id} value={list.id}>
{list.title}
</option>
),
)}
</select>
</div>
</div>
</div>
</div>
{id && (
<div className='fields-group'>
<FiltersLink id={id} />
</div>
)}
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='antennas.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='antennas.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 NewAntenna;

View file

@ -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 { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'bookmark_categories.new.title_placeholder', defaultMessage: 'New category title' },
title: { id: 'bookmark_categories.new.create', defaultMessage: 'Add category' },
});
const mapStateToProps = state => ({
value: state.getIn(['bookmarkCategoryEditor', 'title']),
disabled: state.getIn(['bookmarkCategoryEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
onSubmit: () => dispatch(submitBookmarkCategoryEditor(true)),
});
class NewBookmarkCategoryForm 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(NewBookmarkCategoryForm));

View file

@ -1,98 +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 BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewListForm from './components/new_bookmark_category_form';
const messages = defineMessages({
heading: { id: 'column.bookmark_categories', defaultMessage: 'Bookmark categories' },
subheading: { id: 'bookmark_categories.subheading', defaultMessage: 'Your categories' },
allBookmarks: { id: 'bookmark_categories.all_bookmarks', defaultMessage: 'All bookmarks' },
});
const getOrderedCategories = createSelector([state => state.get('bookmark_categories')], categories => {
if (!categories) {
return categories;
}
return categories.toList().filter(item => !!item && typeof item.get('title') !== 'undefined' && item.get('title') !== null).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
categories: getOrderedCategories(state),
});
class BookmarkCategories extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
categories: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategories());
}
render () {
const { intl, categories, multiColumn } = this.props;
if (!categories) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmark_categories' defaultMessage="You don't have any categories yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='bookmark' iconComponent={BookmarksIcon} multiColumn={multiColumn} />
<NewListForm />
<ColumnLink to='/bookmarks' icon='bookmark' iconComponent={BookmarkIcon} text={intl.formatMessage(messages.allBookmarks)} />
<ScrollableList
scrollKey='bookmark_categories'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{categories.map(category =>
<ColumnLink key={category.get('id')} to={`/bookmark_categories/${category.get('id')}`} icon='bookmark' iconComponent={BookmarkIcon} text={category.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(BookmarkCategories));

View file

@ -0,0 +1,166 @@
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 BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.bookmark_categories',
defaultMessage: 'BookmarkCategories',
},
create: {
id: 'bookmark_categories.create_bookmark_category',
defaultMessage: 'Create bookmark_category',
},
edit: {
id: 'bookmark_categories.edit',
defaultMessage: 'Edit bookmark_category',
},
delete: {
id: 'bookmark_categories.delete',
defaultMessage: 'Delete bookmark_category',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const BookmarkCategoryItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
bookmark_categoryId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.edit),
to: `/bookmark_categories/${id}/edit`,
},
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/bookmark_categories/${id}`} className='lists__item__title'>
<Icon id='bookmark_category-ul' icon={BookmarkIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='bookmark_categories'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const BookmarkCategories: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const bookmark_categories = useAppSelector((state) =>
getOrderedBookmarkCategories(state),
);
useEffect(() => {
dispatch(fetchBookmarkCategories());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='bookmark_categories.no_bookmark_categories_yet'
defaultMessage='No bookmark_categories yet.'
/>
<br />
<FormattedMessage
id='bookmark_categories.create_a_bookmark_category_to_organize'
defaultMessage='Create a new bookmark_category 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='bookmark_category-ul'
iconComponent={BookmarkIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/bookmark_categories/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='bookmark_categories'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{bookmark_categories.map((bookmark_category) => (
<BookmarkCategoryItem
key={bookmark_category.id}
id={bookmark_category.id}
title={bookmark_category.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 BookmarkCategories;

View file

@ -0,0 +1,167 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import { fetchBookmarkCategory } from 'mastodon/actions/bookmark_categories';
import {
createBookmarkCategory,
updateBookmarkCategory,
} from 'mastodon/actions/bookmark_categories_typed';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: {
id: 'column.edit_bookmark_category',
defaultMessage: 'Edit bookmark_category',
},
create: {
id: 'column.create_bookmark_category',
defaultMessage: 'Create bookmark_category',
},
});
const NewBookmarkCategory: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const bookmark_category = useAppSelector((state) =>
id ? state.bookmark_categories.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchBookmarkCategory(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && bookmark_category) {
setTitle(bookmark_category.title);
}
}, [setTitle, id, bookmark_category]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateBookmarkCategory({
id,
title,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createBookmarkCategory({
title,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/bookmark_categories/${result.payload.id}/edit`);
history.push(`/bookmark_categories/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='bookmark_category-ul'
iconComponent={BookmarkIcon}
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='bookmark_category_title'>
<FormattedMessage
id='bookmark_categories.bookmark_category_name'
defaultMessage='BookmarkCategory name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='bookmark_category_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage
id='bookmark_categories.save'
defaultMessage='Save'
/>
) : (
<FormattedMessage
id='bookmark_categories.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 NewBookmarkCategory;

View file

@ -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.map.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));

View file

@ -1,76 +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 BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromBookmarkCategoryAdder, addToBookmarkCategoryAdder } from '../../../actions/bookmark_categories';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'bookmark_categories.status.remove', defaultMessage: 'Remove from bookmark category' },
add: { id: 'bookmark_categories.status.add', defaultMessage: 'Add to bookmark category' },
});
const MapStateToProps = (state, { bookmarkCategoryId, added }) => ({
bookmarkCategory: state.get('bookmark_categories').get(bookmarkCategoryId),
added: typeof added === 'undefined' ? state.getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']).includes(bookmarkCategoryId) : added,
});
const mapDispatchToProps = (dispatch, { bookmarkCategoryId }) => ({
onRemove: () => dispatch(removeFromBookmarkCategoryAdder(bookmarkCategoryId)),
onAdd: () => dispatch(addToBookmarkCategoryAdder(bookmarkCategoryId)),
});
class BookmarkCategory extends ImmutablePureComponent {
static propTypes = {
bookmarkCategory: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { bookmarkCategory, 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='bookmark' icon={BookmarkIcon} className='column-link__icon' fixedWidth />
{bookmarkCategory.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategory));

View file

@ -1,78 +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 { setupBookmarkCategoryAdder, resetBookmarkCategoryAdder } from '../../actions/bookmark_categories';
import NewBookmarkCategoryForm from '../bookmark_categories/components/new_bookmark_category_form';
// import Account from './components/account';
import BookmarkCategory from './components/bookmark_category';
const getOrderedBookmarkCategories = createSelector([state => state.get('bookmark_categories')], bookmarkCategories => {
if (!bookmarkCategories) {
return bookmarkCategories;
}
return bookmarkCategories.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
bookmarkCategoryIds: getOrderedBookmarkCategories(state).map(bookmarkCategory=>bookmarkCategory.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: statusId => dispatch(setupBookmarkCategoryAdder(statusId)),
onReset: () => dispatch(resetBookmarkCategoryAdder()),
});
class BookmarkCategoryAdder extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
bookmarkCategoryIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, statusId } = this.props;
onInitialize(statusId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { bookmarkCategoryIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
{/*
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
*/}
<NewBookmarkCategoryForm />
<div className='list-adder__lists'>
{bookmarkCategoryIds.map(BookmarkCategoryId => <BookmarkCategory key={BookmarkCategoryId} bookmarkCategoryId={BookmarkCategoryId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategoryAdder));

View file

@ -0,0 +1,239 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import { createBookmarkCategory } from 'mastodon/actions/bookmark_categories_typed';
import {
apiGetAccountBookmarkCategories,
apiAddAccountToBookmarkCategory,
apiRemoveAccountFromBookmarkCategory,
} from 'mastodon/api/bookmark_categories';
import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newBookmarkCategory: {
id: 'bookmark_categories.new_bookmark_category_name',
defaultMessage: 'New bookmark_category name',
},
createBookmarkCategory: {
id: 'bookmark_categories.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const BookmarkCategoryItem: 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='bookmark_category-ul' icon={BookmarkIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewBookmarkCategoryItem: React.FC<{
onCreate: (bookmark_category: ApiBookmarkCategoryJSON) => 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(createBookmarkCategory({ 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='bookmark_category-ul' icon={BookmarkIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newBookmarkCategory)}
/>
</label>
<Button
text={intl.formatMessage(messages.createBookmarkCategory)}
type='submit'
/>
</form>
);
};
const BookmarkCategoryAdder: React.FC<{
statusId: string;
onClose: () => void;
}> = ({ statusId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const bookmark_categories = useAppSelector((state) =>
getOrderedBookmarkCategories(state),
);
const [bookmark_categoryIds, setBookmarkCategoryIds] = useState<string[]>(
[] as string[],
);
useEffect(() => {
dispatch(fetchBookmarkCategories());
apiGetAccountBookmarkCategories(statusId)
.then((data) => {
setBookmarkCategoryIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setBookmarkCategoryIds, statusId]);
const handleToggle = useCallback(
(bookmark_categoryId: string, checked: boolean) => {
if (checked) {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_categoryId,
...currentBookmarkCategoryIds,
]);
apiAddAccountToBookmarkCategory(bookmark_categoryId, statusId).catch(
() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter(
(id) => id !== bookmark_categoryId,
),
);
},
);
} else {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter((id) => id !== bookmark_categoryId),
);
apiRemoveAccountFromBookmarkCategory(
bookmark_categoryId,
statusId,
).catch(() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_categoryId,
...currentBookmarkCategoryIds,
]);
});
}
},
[setBookmarkCategoryIds, statusId],
);
const handleCreate = useCallback(
(bookmark_category: ApiBookmarkCategoryJSON) => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_category.id,
...currentBookmarkCategoryIds,
]);
apiAddAccountToBookmarkCategory(bookmark_category.id, statusId).catch(
() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter(
(id) => id !== bookmark_category.id,
),
);
},
);
},
[setBookmarkCategoryIds, statusId],
);
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='bookmark_categories.add_to_bookmark_categories'
defaultMessage='Add {name} to bookmark_categories'
values={{ name: <strong>@</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewBookmarkCategoryItem onCreate={handleCreate} />
{bookmark_categories.map((bookmark_category) => (
<BookmarkCategoryItem
key={bookmark_category.id}
id={bookmark_category.id}
title={bookmark_category.title}
checked={bookmark_categoryIds.includes(bookmark_category.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkCategoryAdder;

View file

@ -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.map.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));

View file

@ -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 CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromCircleAdder, addToCircleAdder } from '../../../actions/circles';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' },
add: { id: 'circles.account.add', defaultMessage: 'Add to circle' },
});
const MapStateToProps = (state, { circleId, added }) => ({
circle: state.get('circles').get(circleId),
added: typeof added === 'undefined' ? state.getIn(['circleAdder', 'circles', 'items']).includes(circleId) : added,
});
const mapDispatchToProps = (dispatch, { circleId }) => ({
onRemove: () => dispatch(removeFromCircleAdder(circleId)),
onAdd: () => dispatch(addToCircleAdder(circleId)),
});
class Circle extends ImmutablePureComponent {
static propTypes = {
circle: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { circle, 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='user-circle' icon={CircleIcon} className='column-link__icon' fixedWidth />
{circle.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(Circle));

View file

@ -1,77 +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 { setupCircleAdder, resetCircleAdder } from '../../actions/circles';
import NewCircleForm from '../circles/components/new_circle_form';
import Account from './components/account';
import Circle from './components/circle';
// hack
const getOrderedCircles = createSelector([state => state.get('circles')], circles => {
if (!circles) {
return circles;
}
return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
circleIds: getOrderedCircles(state).map(circle=>circle.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupCircleAdder(accountId)),
onReset: () => dispatch(resetCircleAdder()),
});
class CircleAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
circleIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, circleIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewCircleForm />
<div className='list-adder__lists'>
{circleIds.map(CircleId => <Circle key={CircleId} circleId={CircleId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleAdder));

View file

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { fetchCircles } from 'mastodon/actions/circles';
import { createCircle } from 'mastodon/actions/circles_typed';
import {
apiGetAccountCircles,
apiAddAccountToCircle,
apiRemoveAccountFromCircle,
} from 'mastodon/api/circles';
import type { ApiCircleJSON } from 'mastodon/api_types/circles';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newCircle: {
id: 'circles.new_circle_name',
defaultMessage: 'New circle name',
},
createCircle: {
id: 'circles.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const CircleItem: 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='circle-ul' icon={CircleIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewCircleItem: React.FC<{
onCreate: (circle: ApiCircleJSON) => 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(createCircle({ 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='circle-ul' icon={CircleIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newCircle)}
/>
</label>
<Button text={intl.formatMessage(messages.createCircle)} type='submit' />
</form>
);
};
const CircleAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const circles = useAppSelector((state) => getOrderedCircles(state));
const [circleIds, setCircleIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchCircles());
apiGetAccountCircles(accountId)
.then((data) => {
setCircleIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setCircleIds, accountId]);
const handleToggle = useCallback(
(circleId: string, checked: boolean) => {
if (checked) {
setCircleIds((currentCircleIds) => [circleId, ...currentCircleIds]);
apiAddAccountToCircle(circleId, accountId).catch(() => {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circleId),
);
});
} else {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circleId),
);
apiRemoveAccountFromCircle(circleId, accountId).catch(() => {
setCircleIds((currentCircleIds) => [circleId, ...currentCircleIds]);
});
}
},
[setCircleIds, accountId],
);
const handleCreate = useCallback(
(circle: ApiCircleJSON) => {
setCircleIds((currentCircleIds) => [circle.id, ...currentCircleIds]);
apiAddAccountToCircle(circle.id, accountId).catch(() => {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circle.id),
);
});
},
[setCircleIds, 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='circles.add_to_circles'
defaultMessage='Add {name} to circles'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewCircleItem onCreate={handleCreate} />
{circles.map((circle) => (
<CircleItem
key={circle.id}
id={circle.id}
title={circle.title}
checked={circleIds.includes(circle.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default CircleAdder;

View file

@ -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 { changeCircleEditorTitle, submitCircleEditor } from 'mastodon/actions/circles';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'circles.new.title_placeholder', defaultMessage: 'New circle title' },
title: { id: 'circles.new.create', defaultMessage: 'Add circle' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'title']),
disabled: state.getIn(['circleEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeCircleEditorTitle(value)),
onSubmit: () => dispatch(submitCircleEditor(true)),
});
class NewCircleForm 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(NewCircleForm));

View file

@ -1,125 +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 CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import { fetchCircles, deleteCircle } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewCircleForm from './components/new_circle_form';
const messages = defineMessages({
heading: { id: 'column.circles', defaultMessage: 'Circles' },
subheading: { id: 'circles.subheading', defaultMessage: 'Your circles' },
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
});
const getOrderedCircles = createSelector([state => state.get('circles')], circles => {
if (!circles) {
return circles;
}
return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
circles: getOrderedCircles(state),
});
class Circles extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
circles: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircles());
}
handleEditClick = (e) => {
e.preventDefault();
this.props.dispatch(openModal({
modalType: 'CIRCLE_EDITOR',
modalProps: { circleId: e.currentTarget.getAttribute('data-id') },
}));
};
handleRemoveClick = (e) => {
const { dispatch, intl } = this.props;
e.preventDefault();
const id = e.currentTarget.getAttribute('data-id');
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
},
},
}));
};
render () {
const { intl, circles, multiColumn } = this.props;
if (!circles) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.circles' defaultMessage="You don't have any circles yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='user-circle' iconComponent={CirclesIcon} multiColumn={multiColumn} />
<NewCircleForm />
<ScrollableList
scrollKey='circles'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{circles.map(circle =>
<ColumnLink key={circle.get('id')} to={`/circles/${circle.get('id')}`} icon='user-circle' iconComponent={CircleIcon} text={circle.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Circles));

View file

@ -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 CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchCircles } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.circles', defaultMessage: 'Circles' },
create: { id: 'circles.create_circle', defaultMessage: 'Create circle' },
edit: { id: 'circles.edit', defaultMessage: 'Edit circle' },
delete: { id: 'circles.delete', defaultMessage: 'Delete circle' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const CircleItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
circleId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/circles/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/circles/${id}`} className='lists__item__title'>
<Icon id='circle-ul' icon={CircleIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='circles'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Circles: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const circles = useAppSelector((state) => getOrderedCircles(state));
useEffect(() => {
dispatch(fetchCircles());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='circles.no_circles_yet'
defaultMessage='No circles yet.'
/>
<br />
<FormattedMessage
id='circles.create_a_circle_to_organize'
defaultMessage='Create a new circle 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='circle-ul'
iconComponent={CircleIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/circles/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='circles'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{circles.map((circle) => (
<CircleItem key={circle.id} id={circle.id} title={circle.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 Circles;

View file

@ -0,0 +1,376 @@
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 CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowers } from 'mastodon/actions/accounts';
import { fetchCircle } from 'mastodon/actions/circles';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToCircle,
apiRemoveAccountFromCircle,
} from 'mastodon/api/circles';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.circle_members',
defaultMessage: 'Manage circle members',
},
placeholder: {
id: 'circles.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'circles.add_to_circle', defaultMessage: 'Add to circle' },
add: { id: 'circles.add_member', defaultMessage: 'Add' },
remove: { id: 'circles.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;
circleId: string;
partOfCircle: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, circleId, partOfCircle, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfCircle) {
void apiRemoveAccountFromCircle(circleId, accountId);
} else {
void apiAddAccountToCircle(circleId, accountId);
}
onToggle(accountId);
}, [accountId, circleId, partOfCircle, 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(
partOfCircle ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const CircleMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['followers', 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(fetchCircle(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowers(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfCircle = accountIds.includes(accountId);
if (partOfCircle) {
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='circle-ul'
iconComponent={CircleIcon}
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='circle_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link to={`/circles/${id}`} className='button button--block'>
<FormattedMessage id='circles.done' defaultMessage='Done' />
</Link>
</div>
</>
)
}
emptyMessage={
mode === 'remove' ? (
<>
<span>
<FormattedMessage
id='circles.no_members_yet'
defaultMessage='No members yet.'
/>
<br />
<FormattedMessage
id='circles.find_users_to_add'
defaultMessage='Find users to add'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
) : (
<FormattedMessage
id='circles.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<AccountItem
key={accountId}
accountId={accountId}
circleId={id}
partOfCircle={
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 CircleMembers;

View file

@ -0,0 +1,202 @@
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 CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import { fetchCircle } from 'mastodon/actions/circles';
import { createCircle, updateCircle } from 'mastodon/actions/circles_typed';
import { apiGetAccounts } from 'mastodon/api/circles';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_circle', defaultMessage: 'Edit circle' },
create: { id: 'column.create_circle', defaultMessage: 'Create circle' },
});
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={`/circles/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='circles.circle_members'
defaultMessage='Circle members'
/>
</strong>
<FormattedMessage
id='circles.circle_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 NewCircle: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const circle = useAppSelector((state) =>
id ? state.circles.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchCircle(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && circle) {
setTitle(circle.title);
}
}, [setTitle, id, circle]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateCircle({
id,
title,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createCircle({
title,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/circles/${result.payload.id}/edit`);
history.push(`/circles/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='circle-ul'
iconComponent={CircleIcon}
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='circle_title'>
<FormattedMessage
id='circles.circle_name'
defaultMessage='Circle name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='circle_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
{id && (
<div className='fields-group'>
<MembersLink id={id} />
</div>
)}
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='circles.save' defaultMessage='Save' />
) : (
<FormattedMessage id='circles.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 NewCircle;

View file

@ -1,45 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder/account, circle_adder/account
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));

View file

@ -1,82 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder/antenna, circle_adder/circle, bookmark_category_adder/bookmark_category
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 VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
exclusive: { id: 'lists.exclusive', defaultMessage: 'Hide list or antenna account posts from home' },
});
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} />;
}
const exclusiveIcon = list.get('exclusive') && <Icon id='eye-slash' icon={VisibilityOffIcon} title={intl.formatMessage(messages.exclusive)} className='column-link__icon' fixedWidth />;
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
{exclusiveIcon}
{list.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));

View file

@ -1,78 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder, circle_adder, bookmark_category_adder
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));

View file

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { createList } from 'mastodon/actions/lists_typed';
import {
apiGetAccountLists,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiListJSON } from 'mastodon/api_types/lists';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newList: {
id: 'lists.new_list_name',
defaultMessage: 'New list name',
},
createList: {
id: 'lists.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const ListItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewListItem: React.FC<{
onCreate: (list: ApiListJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createList({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newList)}
/>
</label>
<Button text={intl.formatMessage(messages.createList)} type='submit' />
</form>
);
};
const ListAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const lists = useAppSelector((state) => getOrderedLists(state));
const [listIds, setListIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchLists());
apiGetAccountLists(accountId)
.then((data) => {
setListIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setListIds, accountId]);
const handleToggle = useCallback(
(listId: string, checked: boolean) => {
if (checked) {
setListIds((currentListIds) => [listId, ...currentListIds]);
apiAddAccountToList(listId, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
});
} else {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
apiRemoveAccountFromList(listId, accountId).catch(() => {
setListIds((currentListIds) => [listId, ...currentListIds]);
});
}
},
[setListIds, accountId],
);
const handleCreate = useCallback(
(list: ApiListJSON) => {
setListIds((currentListIds) => [list.id, ...currentListIds]);
apiAddAccountToList(list.id, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== list.id),
);
});
},
[setListIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='lists.add_to_lists'
defaultMessage='Add {name} to lists'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewListItem onCreate={handleCreate} />
{lists.map((list) => (
<ListItem
key={list.id}
id={list.id}
title={list.title}
checked={listIds.includes(list.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default ListAdder;

View file

@ -1,84 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/account, circle_editor/account
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));

View file

@ -1,78 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/edit_antenna_form, circle_editor/edit_circle_form, bookmark_category_editor/edit_bookmark_category_form
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));

View file

@ -1,85 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/search, circle_editor/search
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -1,85 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor, circle_editor, bookmark_category_statuses
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));

View file

@ -3,21 +3,19 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, updateList } from 'mastodon/actions/lists';
import { fetchList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { connectListStream } from 'mastodon/actions/streaming';
import { expandListTimeline } from 'mastodon/actions/timelines';
@ -25,17 +23,10 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
});
const mapStateToProps = (state, props) => ({
list: state.getIn(['lists', props.params.id]),
hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
@ -117,13 +108,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;
@ -131,38 +115,11 @@ class ListTimeline extends PureComponent {
dispatch(openModal({ modalType: 'CONFIRM_DELETE_LIST', modalProps: { listId: id, columnId } }));
};
handleEditAntennaClick = (e) => {
const id = e.currentTarget.getAttribute('data-id');
this.props.history.push(`/antennasw/${id}/edit`);
};
handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value, undefined));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined, undefined));
};
onNotifyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, undefined, target.checked));
};
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;
const isNotify = list ? list.get('notify') : undefined;
const antennas = list ? (list.get('antennas')?.toArray() || []) : [];
if (typeof list === 'undefined') {
return (
@ -193,60 +150,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 list or antenna account posts from home' />
</label>
</div>
</section>
<section className='similar-row'>
<div className='setting-toggle'>
<Toggle id={`list-${id}-notify`} checked={isNotify} onChange={this.onNotifyToggle} />
<label htmlFor={`list-${id}-notify`} className='setting-toggle__label'>
<FormattedMessage id='lists.notify' defaultMessage='Notify these posts' />
</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>
)}
{ antennas.length > 0 && (
<section aria-labelledby={`list-${id}-antenna`}>
<h3><FormattedMessage id='lists.antennas' defaultMessage='Related antennas:' /></h3>
<ul className='column-settings__row'>
{ antennas.map(antenna => (
<li key={antenna.get('id')} className='column-settings__row__antenna'>
<button type='button' className='text-btn column-header__setting-btn' data-id={antenna.get('id')} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' icon={EditIcon} /> {antenna.get('title')}{antenna.get('stl') && ' [STL]'}
</button>
</li>
))}
</ul>
</section>
)}
</div>
</ColumnHeader>
@ -269,4 +180,4 @@ class ListTimeline extends PureComponent {
}
export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline)));
export default withRouter(connect(mapStateToProps)(ListTimeline));

View file

@ -1,82 +0,0 @@
// Kmyblue tracking marker: copied antennas/new_antenna_form, circles/new_circle_form, bookmark_categories/new_bookmark_category_form
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeListEditorTitle, submitListEditor } from 'mastodon/actions/lists';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
title: { id: 'lists.new.create', defaultMessage: 'Add list' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: state.getIn(['listEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(true)),
});
class NewListForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewListForm));

View file

@ -1,98 +0,0 @@
// Kmyblue tracking marker: copied antennas, circles, bookmark_categories
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewListForm from './components/new_list_form';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
with_antenna: { id: 'lists.with_antenna', defaultMessage: 'Antenna' },
});
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
});
class Lists extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchLists());
}
render () {
const { intl, lists, multiColumn } = this.props;
if (!lists) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='list-ul' iconComponent={ListAltIcon} multiColumn={multiColumn} />
<NewListForm />
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{lists.map(list =>
(<ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' iconComponent={ListAltIcon} text={list.get('title')}
badge={(list.get('antennas') && list.get('antennas').size > 0) ? intl.formatMessage(messages.with_antenna) : undefined} />),
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Lists));

View file

@ -0,0 +1,145 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.lists', defaultMessage: 'Lists' },
create: { id: 'lists.create_list', defaultMessage: 'Create list' },
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
delete: { id: 'lists.delete', defaultMessage: 'Delete list' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const ListItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_LIST',
modalProps: {
listId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/lists/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/lists/${id}`} className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='lists'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Lists: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const lists = useAppSelector((state) => getOrderedLists(state));
useEffect(() => {
dispatch(fetchLists());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='lists.no_lists_yet'
defaultMessage='No lists yet.'
/>
<br />
<FormattedMessage
id='lists.create_a_list_to_organize'
defaultMessage='Create a new list to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/lists/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='lists'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{lists.map((list) => (
<ListItem key={list.id} id={list.id} title={list.title} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Lists;

View file

@ -0,0 +1,373 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowing } from 'mastodon/actions/accounts';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { fetchList } from 'mastodon/actions/lists';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
placeholder: {
id: 'lists.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'lists.add_to_list', defaultMessage: 'Add to list' },
add: { id: 'lists.add_member', defaultMessage: 'Add' },
remove: { id: 'lists.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
listId: string;
partOfList: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, listId, partOfList, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfList) {
void apiRemoveAccountFromList(listId, accountId);
} else {
void apiAddAccountToList(listId, accountId);
}
onToggle(accountId);
}, [accountId, listId, partOfList, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfList ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const ListMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['following', me, 'items']) as string[],
);
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchList(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfList = accountIds.includes(accountId);
if (partOfList) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
extraButton={
<button
onClick={handleSearchClick}
type='button'
className='column-header__button'
title={intl.formatMessage(messages.enterSearch)}
aria-label={intl.formatMessage(messages.enterSearch)}
>
<Icon id='plus' icon={AddIcon} />
</button>
}
/>
) : (
<ColumnSearchHeader
onBack={handleDismissSearchClick}
onSubmit={handleSearch}
/>
)}
<ScrollableList
scrollKey='list_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
{displayedAccountIds.length > 0 && <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;

View file

@ -0,0 +1,342 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory, Link } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import Toggle from 'react-toggle';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchList } from 'mastodon/actions/lists';
import { createList, updateList } from 'mastodon/actions/lists_typed';
import { apiGetAccounts } from 'mastodon/api/lists';
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
create: { id: 'column.create_list', defaultMessage: 'Create list' },
});
const MembersLink: React.FC<{
id: string;
}> = ({ id }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
useEffect(() => {
void apiGetAccounts(id)
.then((data) => {
setCount(data.length);
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars]);
return (
<Link to={`/lists/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='lists.list_members'
defaultMessage='List members'
/>
</strong>
<FormattedMessage
id='lists.list_members_count'
defaultMessage='{count, plural, one {# member} other {# members}}'
values={{ count }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
))}
</div>
</Link>
);
};
const NewList: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const list = useAppSelector((state) =>
id ? state.lists.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [exclusive, setExclusive] = useState(false);
const [repliesPolicy, setRepliesPolicy] = useState<RepliesPolicyType>('list');
const [notify, setNotify] = useState(false);
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);
setNotify(list.notify);
}
}, [setTitle, setExclusive, setRepliesPolicy, setNotify, 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 handleNotifyChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setNotify(checked);
},
[setNotify],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateList({
id,
title,
exclusive,
replies_policy: repliesPolicy,
notify,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createList({
title,
exclusive,
replies_policy: repliesPolicy,
notify,
}),
).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,
notify,
]);
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='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.notify'
defaultMessage='Notify list'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='lists.notify_hint'
defaultMessage='Notify when new post is added.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle checked={notify} onChange={handleNotifyChange} />
</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;

View file

@ -43,7 +43,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
);
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ clientX, clientY, target, button }) => {
({ clientX, clientY, target, button, ctrlKey, metaKey }) => {
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
const [deltaX, deltaY] = [
Math.abs(clientX - startX),
@ -64,8 +64,14 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
element = element.parentNode as HTMLDivElement | null;
}
if (deltaX + deltaY < 5 && button === 0 && account) {
history.push(`/@${account.acct}/${statusId}`);
if (deltaX + deltaY < 5 && account) {
const path = `/@${account.acct}/${statusId}`;
if (button === 0 && !(ctrlKey || metaKey)) {
history.push(path);
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
}
}
clickCoordinatesRef.current = null;

View file

@ -8,7 +8,7 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import Immutable from 'immutable';
import { is } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
@ -73,7 +73,7 @@ export default class Card extends PureComponent {
};
UNSAFE_componentWillReceiveProps (nextProps) {
if (!Immutable.is(this.props.card, nextProps.card)) {
if (!is(this.props.card, nextProps.card)) {
this.setState({ embedded: false, previewLoaded: false });
}

View file

@ -7,7 +7,7 @@ import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import Immutable from 'immutable';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
@ -87,7 +87,7 @@ const makeMapStateToProps = () => {
const getPictureInPicture = makeGetPictureInPicture();
const getReferenceIds = createSelector([
(state, { id }) => state.getIn(['contexts', 'references', id]) || Immutable.List(),
(state, { id }) => state.getIn(['contexts', 'references', id]) || ImmutableList(),
], (references) => {
return references;
});
@ -96,7 +96,7 @@ const makeMapStateToProps = () => {
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List();
let ancestorsIds = ImmutableList();
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId;
@ -143,15 +143,15 @@ const makeMapStateToProps = () => {
});
}
return Immutable.List(descendantsIds);
return ImmutableList(descendantsIds);
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
let referenceIds = Immutable.List();
let ancestorsIds = ImmutableList();
let descendantsIds = ImmutableList();
let referenceIds = ImmutableList();
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });

View file

@ -23,7 +23,7 @@ const getAccountLanguages = createSelector([
(state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()),
state => state.get('statuses'),
], (statusIds, statuses) =>
new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language'))));
const mapStateToProps = (state, { accountId }) => ({
acct: state.getIn(['accounts', accountId, 'acct']),

View file

@ -10,11 +10,8 @@ import {
DomainBlockModal,
ReportModal,
EmbedModal,
ListEditor,
ListAdder,
AntennaEditor,
AntennaAdder,
CircleEditor,
CircleAdder,
BookmarkCategoryAdder,
CompareHistoryModal,
@ -69,9 +66,6 @@ export const MODAL_COMPONENTS = {
'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'ANTENNA_EDITOR': AntennaEditor,
'CIRCLE_EDITOR': CircleEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder,

View file

@ -65,22 +65,30 @@ import {
FollowedTags,
LinkTimeline,
ListTimeline,
Lists,
ListEdit,
ListMembers,
Blocks,
DomainBlocks,
Mutes,
PinnedStatuses,
Lists,
Antennas,
Circles,
CircleStatuses,
AntennaSetting,
Directory,
Explore,
ReactionDeck,
Onboarding,
About,
PrivacyPolicy,
CommunityTimeline,
AntennaEdit,
AntennaExcludeMembers,
AntennaMembers,
CircleEdit,
CircleMembers,
BookmarkCategoryEdit,
ReactionDeck,
Onboarding,
Directory,
Explore,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
@ -220,9 +228,23 @@ 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='/antennas/new' component={AntennaEdit} content={children} />
<WrappedRoute path='/antennas/:id/edit' component={AntennaEdit} content={children} />
<WrappedRoute path='/antennas/:id/members' component={AntennaMembers} content={children} />
<WrappedRoute path='/antennas/:id/exclude_members' component={AntennaExcludeMembers} content={children} />
<WrappedRoute path='/antennasw/:id' component={AntennaSetting} content={children} />
<WrappedRoute path='/antennast/:id' component={AntennaTimeline} content={children} />
<WrappedRoute path='/circles/new' component={CircleEdit} content={children} />
<WrappedRoute path='/circles/:id/edit' component={CircleEdit} content={children} />
<WrappedRoute path='/circles/:id/members' component={CircleMembers} content={children} />
<WrappedRoute path='/circles/:id' component={CircleStatuses} content={children} />
<WrappedRoute path='/bookmark_categories/new' component={BookmarkCategoryEdit} content={children} />
<WrappedRoute path='/bookmark_categories/:id/edit' component={BookmarkCategoryEdit} content={children} />
<WrappedRoute path='/bookmark_categories/:id' component={BookmarkCategoryStatuses} content={children} />
<WrappedRoute path='/notifications' component={NotificationsWrapper} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
<WrappedRoute path='/notifications/requests/:id' component={NotificationRequest} content={children} exact />
@ -230,8 +252,6 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/bookmark_categories/:id' component={BookmarkCategoryStatuses} content={children} />
<WrappedRoute path='/bookmark_categories' component={BookmarkCategories} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} />
@ -270,8 +290,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/antennasw' component={Antennas} content={children} />
<WrappedRoute path='/circles/:id' component={CircleStatuses} content={children} />
<WrappedRoute path='/circles' component={Circles} content={children} />
<WrappedRoute path='/bookmark_categories' component={BookmarkCategories} content={children} />
<Route component={BundleColumnError} />
</WrappedSwitch>

View file

@ -194,10 +194,6 @@ export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
}
export function ListEditor () {
return import(/* webpackChunkName: "features/list_editor" */'../../list_editor');
}
export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
}
@ -289,3 +285,35 @@ export function LinkTimeline () {
export function AnnualReportModal () {
return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal');
}
export function ListEdit () {
return import(/*webpackChunkName: "features/lists" */'../../lists/new');
}
export function ListMembers () {
return import(/* webpackChunkName: "features/lists" */'../../lists/members');
}
export function AntennaEdit () {
return import(/*webpackChunkName: "features/antennas" */'../../antennas/new');
}
export function AntennaMembers () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas/members');
}
export function AntennaExcludeMembers () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas/exclude_members');
}
export function CircleEdit () {
return import(/*webpackChunkName: "features/circles" */'../../circles/new');
}
export function CircleMembers () {
return import(/* webpackChunkName: "features/circles" */'../../circles/members');
}
export function BookmarkCategoryEdit () {
return import(/*webpackChunkName: "features/bookmark_categories" */'../../bookmark_categories/new');
}