1
0
Fork 0
forked from gitea/nas

Add circle editor

This commit is contained in:
KMY 2023-08-21 16:37:35 +09:00
parent c97e63bb18
commit b0854b1dd8
33 changed files with 1671 additions and 31 deletions

View file

@ -0,0 +1,43 @@
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

@ -0,0 +1,72 @@
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 { 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' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='user-circle' 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

@ -0,0 +1,76 @@
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 { createSelector } from 'reselect';
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,79 @@
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 { removeFromCircleEditor, addToCircleEditor } from '../../../actions/circles';
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: 'circles.account.remove', defaultMessage: 'Remove from circle' },
add: { id: 'circles.account.add', defaultMessage: 'Add to circle' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['circleEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromCircleEditor(accountId)),
onAdd: () => dispatch(addToCircleEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.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' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' 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

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeCircleEditorTitle, submitCircleEditor } from '../../../actions/circles';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'circles.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'title']),
disabled: !state.getIn(['circleEditor', 'isChanged']) || !state.getIn(['circleEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeCircleEditorTitle(value)),
onSubmit: () => dispatch(submitCircleEditor(false)),
});
class CircleForm 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'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleForm));

View file

@ -0,0 +1,81 @@
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 { Icon } from 'mastodon/components/icon';
import { fetchCircleSuggestions, clearCircleSuggestions, changeCircleSuggestions } from '../../../actions/circles';
const messages = defineMessages({
search: { id: 'circles.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchCircleSuggestions(value)),
onClear: () => dispatch(clearCircleSuggestions()),
onChange: value => dispatch(changeCircleSuggestions(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' className={classNames({ active: !hasValue })} />
<Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -0,0 +1,83 @@
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 { setupCircleEditor, clearCircleSuggestions, resetCircleEditor } from '../../actions/circles';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditCircleForm from './components/edit_circle_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['circleEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['circleEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: circleId => dispatch(setupCircleEditor(circleId)),
onClear: () => dispatch(clearCircleSuggestions()),
onReset: () => dispatch(resetCircleEditor()),
});
class CircleEditor extends ImmutablePureComponent {
static propTypes = {
circleId: 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, circleId } = this.props;
onInitialize(circleId);
}
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'>
<EditCircleForm />
<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(CircleEditor));

View file

@ -0,0 +1,80 @@
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

@ -0,0 +1,126 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
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 { IconButton } from 'mastodon/components/icon_button';
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: 'circles.subheading', defaultMessage: 'Your circles' },
deleteConfirm: { id: 'circles.subheading', defaultMessage: 'Your circles' },
});
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' multiColumn={multiColumn} />
<NewCircleForm />
<ScrollableList
scrollKey='circles'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{circles.map(circle =>
(<div key={circle.get('id')} className='circle-item'>
<ColumnLink to={`#`} data-id={circle.get('id')} onClick={this.handleEditClick} icon='user-circle' text={circle.get('title')} />,
<IconButton icon='trash' data_id={circle.get('id')} onClick={this.handleRemoveClick} />
</div>)
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Circles));

View file

@ -13,6 +13,8 @@ import {
ListAdder,
AntennaEditor,
AntennaAdder,
CircleEditor,
CircleAdder,
CompareHistoryModal,
FilterModal,
InteractionModal,
@ -48,9 +50,11 @@ export const MODAL_COMPONENTS = {
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'ANTENNA_EDITOR': AntennaEditor,
'CIRCLE_EDITOR': CircleEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder,
'CIRCLE_ADDER': CircleAdder,
'COMPARE_HISTORY': CompareHistoryModal,
'FILTER': FilterModal,
'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal,

View file

@ -28,6 +28,7 @@ const messages = defineMessages({
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
@ -106,6 +107,7 @@ class NavigationPanel extends Component {
<>
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ColumnLink transparent to='/antennasw' icon='wifi' text={intl.formatMessage(messages.antennas)} isActive={this.isAntennasActive} />
<ColumnLink transparent to='/circles' icon='user-circle' text={intl.formatMessage(messages.circles)} />
<FollowRequestsColumnLink />
<ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
</>

View file

@ -62,6 +62,7 @@ import {
PinnedStatuses,
Lists,
Antennas,
Circles,
AntennaSetting,
Directory,
Explore,
@ -254,6 +255,7 @@ 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' component={Circles} content={children} />
<Route component={BundleColumnError} />
</WrappedSwitch>

View file

@ -50,6 +50,10 @@ export function Antennas () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas');
}
export function Circles () {
return import(/* webpackChunkName: "features/circles" */'../../circles');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
@ -170,6 +174,14 @@ export function AntennaEditor () {
return import(/*webpackChunkName: "features/antenna_editor" */'../../antenna_editor');
}
export function CircleAdder () {
return import(/*webpackChunkName: "features/circle_adder" */'../../circle_adder');
}
export function CircleEditor () {
return import(/*webpackChunkName: "features/circle_editor" */'../../circle_editor');
}
export function AntennaSetting () {
return import(/*webpackChunkName: "features/antenna_setting" */'../../antenna_setting');
}