Wip: bookmark categories

This commit is contained in:
KMY 2023-08-26 10:26:45 +09:00
parent 020e50d0c5
commit f6bdd9b6de
13 changed files with 866 additions and 1 deletions

View file

@ -0,0 +1,379 @@
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_FETCH_FAIL = 'BOOKMARK_CATEGORY_FETCH_FAIL';
export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQUEST';
export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE';
export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET';
export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP';
export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST';
export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS';
export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL';
export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST';
export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS';
export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL';
export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST';
export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS';
export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL';
export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET';
export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL';
export const fetchBookmarkCategory = id => (dispatch, getState) => {
if (getState().getIn(['bookmark_categories', id])) {
return;
}
dispatch(fetchBookmarkCategoryRequest(id));
api(getState).get(`/api/v1/bookmark_categories/${id}`)
.then(({ data }) => dispatch(fetchBookmarkCategorySuccess(data)))
.catch(err => dispatch(fetchBookmarkCategoryFail(id, err)));
};
export const fetchBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_FETCH_REQUEST,
id,
});
export const fetchBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_FETCH_SUCCESS,
bookmarkCategory,
});
export const fetchBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_FETCH_FAIL,
id,
error,
});
export const fetchBookmarkCategories = () => (dispatch, getState) => {
dispatch(fetchBookmarkCategoriesRequest());
api(getState).get('/api/v1/bookmark_categories')
.then(({ data }) => dispatch(fetchBookmarkCategoriesSuccess(data)))
.catch(err => dispatch(fetchBookmarkCategoriesFail(err)));
};
export const fetchBookmarkCategoriesRequest = () => ({
type: BOOKMARK_CATEGORIES_FETCH_REQUEST,
});
export const fetchBookmarkCategoriesSuccess = bookmarkCategories => ({
type: BOOKMARK_CATEGORIES_FETCH_SUCCESS,
bookmarkCategories,
});
export const fetchBookmarkCategoriesFail = error => ({
type: BOOKMARK_CATEGORIES_FETCH_FAIL,
error,
});
export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => {
const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']);
const title = getState().getIn(['bookmarkCategoryEditor', 'title']);
if (bookmarkCategoryId === null) {
dispatch(createBookmarkCategory(title, shouldReset));
} else {
dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset));
}
};
export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_EDITOR_SETUP,
bookmarkCategory: getState().getIn(['bookmarkCategories', bookmarkCategoryId]),
});
dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId));
};
export const changeBookmarkCategoryEditorTitle = value => ({
type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
value,
});
export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => {
dispatch(createBookmarkCategoryRequest());
api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => {
dispatch(createBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(createBookmarkCategoryFail(err)));
};
export const createBookmarkCategoryRequest = () => ({
type: BOOKMARK_CATEGORY_CREATE_REQUEST,
});
export const createBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_CREATE_SUCCESS,
bookmarkCategory,
});
export const createBookmarkCategoryFail = error => ({
type: BOOKMARK_CATEGORY_CREATE_FAIL,
error,
});
export const updateBookmarkCategory = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateBookmarkCategoryRequest(id));
api(getState).put(`/api/v1/bookmark_categories/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(updateBookmarkCategoryFail(id, err)));
};
export const updateBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_UPDATE_REQUEST,
id,
});
export const updateBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_UPDATE_SUCCESS,
bookmarkCategory,
});
export const updateBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_UPDATE_FAIL,
id,
error,
});
export const resetBookmarkCategoryEditor = () => ({
type: BOOKMARK_CATEGORY_EDITOR_RESET,
});
export const deleteBookmarkCategory = id => (dispatch, getState) => {
dispatch(deleteBookmarkCategoryRequest(id));
api(getState).delete(`/api/v1/bookmark_categories/${id}`)
.then(() => dispatch(deleteBookmarkCategorySuccess(id)))
.catch(err => dispatch(deleteBookmarkCategoryFail(id, err)));
};
export const deleteBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_DELETE_REQUEST,
id,
});
export const deleteBookmarkCategorySuccess = id => ({
type: BOOKMARK_CATEGORY_DELETE_SUCCESS,
id,
});
export const deleteBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_DELETE_FAIL,
id,
error,
});
export const fetchBookmarkCategoryStatuses = bookmarkCategoryId => (dispatch, getState) => {
dispatch(fetchBookmarkCategoryStatusesRequest(bookmarkCategoryId));
api(getState).get(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedStatuses(data));
dispatch(fetchBookmarkCategoryStatusesSuccess(bookmarkCategoryId, data));
}).catch(err => dispatch(fetchBookmarkCategoryStatusesFail(bookmarkCategoryId, err)));
};
export const fetchBookmarkCategoryStatusesRequest = id => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST,
id,
});
export const fetchBookmarkCategoryStatusesSuccess = (id, statuses, next) => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS,
id,
statuses,
next,
});
export const fetchBookmarkCategoryStatusesFail = (id, error) => ({
type: BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL,
id,
error,
});
export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] })
.then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } })
.then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const resetBookmarkCategoryAdder = () => ({
type: BOOKMARK_CATEGORY_ADDER_RESET,
});
export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_ADDER_SETUP,
status: getState().getIn(['statuses', statusId]),
});
dispatch(fetchBookmarkCategories());
dispatch(fetchStatusBookmarkCategories(statusId));
};
export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => {
dispatch(fetchStatusBookmarkCategoriesRequest(statusId));
api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`)
.then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data)))
.catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err)));
};
export const fetchStatusBookmarkCategoriesRequest = id => ({
type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
id,
});
export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
id,
bookmarkCategories,
});
export const fetchStatusBookmarkCategoriesFail = (id, err) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
id,
err,
});
export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
};
export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
};
export function expandBookmarkCategoryStatuses(bookmarkCategoryId) {
return (dispatch, getState) => {
const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null);
if (url === null || getState().getIn(['bookmark_categories', bookmarkCategoryId, 'isLoading'])) {
return;
}
dispatch(expandBookmarkCategoryStatusesRequest(bookmarkCategoryId));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandBookmarkCategoryStatusesSuccess(bookmarkCategoryId, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandBookmarkCategoryStatusesFail(bookmarkCategoryId, error));
});
};
}
export function expandBookmarkCategoryStatusesRequest(id) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST,
id,
};
}
export function expandBookmarkCategoryStatusesSuccess(id, statuses, next) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS,
id,
statuses,
next,
};
}
export function expandBookmarkCategoryStatusesFail(id, error) {
return {
type: BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL,
id,
error,
};
}

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 { 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

@ -0,0 +1,93 @@
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 { 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_list_form';
const messages = defineMessages({
heading: { id: 'column.bookmark_categories', defaultMessage: 'Bookmark categories' },
subheading: { id: 'bookmark_categories_ex.subheading', defaultMessage: 'Your categories' },
});
const getOrderedCategories = createSelector([state => state.get('bookmark_categories')], categories => {
if (!categories) {
return categories;
}
return categories.toList().filter(item => !!item).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='list-ul' multiColumn={multiColumn} />
<NewListForm />
<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' 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,132 @@
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 { debounce } from 'lodash';
import { expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses } from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = (state, { params }) => ({
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
statusIds: getBookmarkCategoryStatusList(state, params.id),
isLoading: state.getIn(['bookmark_categories', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['bookmark_categories', params.id, 'next']),
});
class BookmarkCategoryStatuses extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
bookmarkCategory: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategory(this.props.params.id));
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
}
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS_EX', {}));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
setRef = c => {
this.column = c;
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandBookmarkCategoryStatuses());
}, 300, { leading: true });
render () {
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof bookmarkCategory === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (bookmarkCategory === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='bookmark_ex'
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`bookmark_ex_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(BookmarkCategoryStatuses));

View file

@ -117,7 +117,7 @@ class NavigationPanel extends Component {
{signedIn && ( {signedIn && (
<> <>
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmark_categories' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<hr /> <hr />

View file

@ -54,6 +54,8 @@ import {
FavouritedStatuses, FavouritedStatuses,
EmojiReactedStatuses, EmojiReactedStatuses,
BookmarkedStatuses, BookmarkedStatuses,
BookmarkCategories,
BookmarkCategoryStatuses,
FollowedTags, FollowedTags,
ListTimeline, ListTimeline,
Blocks, Blocks,
@ -218,6 +220,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} /> <WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} 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='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} /> <WrappedRoute path='/reaction_deck' component={ReactionDeck} content={children} />

View file

@ -122,6 +122,14 @@ export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses'); return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
} }
export function BookmarkCategories () {
return import(/* webpackChunkName: "features/bookmark_categories" */'../../bookmark_categories');
}
export function BookmarkCategoryStatuses () {
return import(/* webpackChunkName: "features/bookmark_category_statuses" */'../../bookmark_category_statuses');
}
export function Blocks () { export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks'); return import(/* webpackChunkName: "features/blocks" */'../../blocks');
} }

View file

@ -0,0 +1,71 @@
import { Map as ImmutableMap, fromJS, OrderedSet as ImmutableOrderedSet } from 'immutable';
import {
BOOKMARK_CATEGORY_FETCH_SUCCESS,
BOOKMARK_CATEGORY_FETCH_FAIL,
BOOKMARK_CATEGORIES_FETCH_SUCCESS,
BOOKMARK_CATEGORY_CREATE_SUCCESS,
BOOKMARK_CATEGORY_UPDATE_SUCCESS,
BOOKMARK_CATEGORY_DELETE_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST,
BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL,
BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST,
BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS,
BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL,
} from '../actions/bookmark_categories';
const initialState = ImmutableMap();
const normalizeBookmarkCategory = (state, category) => state.set(category.id, fromJS(category));
const normalizeBookmarkCategories = (state, bookmarkCategories) => {
bookmarkCategories.forEach(bookmarkCategory => {
state = normalizeBookmarkCategory(state, bookmarkCategory);
});
return state;
};
const normalizeBookmarkCategoryStatuses = (state, bookmaryCategoryId, statuses, next) => {
return state.updateIn([bookmaryCategoryId, 'items'], listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('loaded', true);
map.set('isLoading', false);
map.set('items', ImmutableOrderedSet(statuses.map(item => item.id)));
}));
};
const appendToBookmarkCategoryStatuses = (state, bookmarkCategoryId, statuses, next) => {
return state.updateIn([bookmarkCategoryId, 'items'], listMap => listMap.withMutations(map => {
map.set('next', next);
map.set('isLoading', false);
map.set('items', map.get('items').union(statuses.map(item => item.id)));
}));
};
export default function bookmarkCategories(state = initialState, action) {
switch(action.type) {
case BOOKMARK_CATEGORY_FETCH_SUCCESS:
case BOOKMARK_CATEGORY_CREATE_SUCCESS:
case BOOKMARK_CATEGORY_UPDATE_SUCCESS:
return normalizeBookmarkCategory(state, action.bookmarkCategory);
case BOOKMARK_CATEGORIES_FETCH_SUCCESS:
return normalizeBookmarkCategories(state, action.bookmarkCategories);
case BOOKMARK_CATEGORY_DELETE_SUCCESS:
case BOOKMARK_CATEGORY_FETCH_FAIL:
return state.set(action.id, false);
case BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST:
case BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST:
return state.setIn([action.id, 'isLoading'], true);
case BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL:
case BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL:
return state.setIn([action.id, 'isLoading'], false);
case BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS:
return normalizeBookmarkCategoryStatuses(state, action.id, action.statuses, action.next);
case BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS:
return appendToBookmarkCategoryStatuses(state, action.id, action.statuses, action.next);
default:
return state;
}
}

View file

@ -0,0 +1,74 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
BOOKMARK_CATEGORY_CREATE_REQUEST,
BOOKMARK_CATEGORY_CREATE_FAIL,
BOOKMARK_CATEGORY_CREATE_SUCCESS,
BOOKMARK_CATEGORY_UPDATE_REQUEST,
BOOKMARK_CATEGORY_UPDATE_FAIL,
BOOKMARK_CATEGORY_UPDATE_SUCCESS,
BOOKMARK_CATEGORY_EDITOR_RESET,
BOOKMARK_CATEGORY_EDITOR_SETUP,
BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
} from '../actions/bookmark_categories';
const initialState = ImmutableMap({
bookmaryCategoryId: null,
isSubmitting: false,
isChanged: false,
title: '',
isExclusive: false,
statuses: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function bookmaryCategoryEditorReducer(state = initialState, action) {
switch(action.type) {
case BOOKMARK_CATEGORY_EDITOR_RESET:
return initialState;
case BOOKMARK_CATEGORY_EDITOR_SETUP:
return state.withMutations(map => {
map.set('bookmaryCategoryId', action.bookmaryCategory.get('id'));
map.set('title', action.bookmaryCategory.get('title'));
map.set('isExclusive', action.bookmaryCategory.get('is_exclusive'));
map.set('isSubmitting', false);
});
case BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case BOOKMARK_CATEGORY_CREATE_REQUEST:
case BOOKMARK_CATEGORY_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case BOOKMARK_CATEGORY_CREATE_FAIL:
case BOOKMARK_CATEGORY_UPDATE_FAIL:
return state.set('isSubmitting', false);
case BOOKMARK_CATEGORY_CREATE_SUCCESS:
case BOOKMARK_CATEGORY_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('bookmaryCategoryId', action.bookmaryCategory.id);
});
case BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS:
return state.updateIn(['accounts', 'items'], bookmaryCategory => bookmaryCategory.unshift(action.accountId));
case BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['accounts', 'items'], bookmaryCategory => bookmaryCategory.filterNot(item => item === action.accountId));
default:
return state;
}
}

View file

@ -12,6 +12,8 @@ import antennaAdder from './antenna_adder';
import antennaEditor from './antenna_editor'; import antennaEditor from './antenna_editor';
import antennas from './antennas'; import antennas from './antennas';
import blocks from './blocks'; import blocks from './blocks';
import bookmark_categories from './bookmark_categories';
import bookmarkCategoryEditor from './bookmark_category_editor';
import boosts from './boosts'; import boosts from './boosts';
import circleAdder from './circle_adder'; import circleAdder from './circle_adder';
import circleEditor from './circle_editor'; import circleEditor from './circle_editor';
@ -89,6 +91,8 @@ const reducers = {
circles, circles,
circleEditor, circleEditor,
circleAdder, circleAdder,
bookmark_categories,
bookmarkCategoryEditor,
filters, filters,
conversations, conversations,
suggestions, suggestions,

View file

@ -1,5 +1,8 @@
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
import { ANTENNA_DELETE_SUCCESS, ANTENNA_FETCH_FAIL } from 'mastodon/actions/antennas';
import { CIRCLE_DELETE_SUCCESS, CIRCLE_FETCH_FAIL } from 'mastodon/actions/circles';
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns'; import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } from '../actions/columns';
import { EMOJI_USE } from '../actions/emojis'; import { EMOJI_USE } from '../actions/emojis';
import { LANGUAGE_USE } from '../actions/languages'; import { LANGUAGE_USE } from '../actions/languages';
@ -142,6 +145,10 @@ const updateFrequentLanguages = (state, language) => state.update('frequentlyUse
const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
const filterDeadAntennaColumns = (state, antennaId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'ANTENNA' && column.get('params').get('id') === antennaId));
const filterDeadCircleColumns = (state, circleId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'CIRCLE' && column.get('params').get('id') === circleId));
export default function settings(state = initialState, action) { export default function settings(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
@ -173,6 +180,14 @@ export default function settings(state = initialState, action) {
return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state;
case LIST_DELETE_SUCCESS: case LIST_DELETE_SUCCESS:
return filterDeadListColumns(state, action.id); return filterDeadListColumns(state, action.id);
case ANTENNA_FETCH_FAIL:
return action.error.response.status === 404 ? filterDeadAntennaColumns(state, action.id) : state;
case ANTENNA_DELETE_SUCCESS:
return filterDeadAntennaColumns(state, action.id);
case CIRCLE_FETCH_FAIL:
return action.error.response.status === 404 ? filterDeadCircleColumns(state, action.id) : state;
case CIRCLE_DELETE_SUCCESS:
return filterDeadCircleColumns(state, action.id);
default: default:
return state; return state;
} }

View file

@ -131,3 +131,7 @@ export const getAccountHidden = createSelector([
export const getStatusList = createSelector([ export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']), (state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList()); ], (items) => items.toList());
export const getBookmarkCategoryStatusList = createSelector([
(state, bookmarkCategoryId) => state.getIn(['bookmark_categories', bookmarkCategoryId, 'items']),
], (items) => items ? items.toList() : ImmutableList());

View file

@ -23,6 +23,7 @@ Rails.application.routes.draw do
/favourites /favourites
/emoji_reactions /emoji_reactions
/bookmarks /bookmarks
/bookmark_categories/(*any)
/pinned /pinned
/reaction_deck /reaction_deck
/start /start