Add favourites list
This commit is contained in:
parent
b290979e82
commit
33785332f1
25 changed files with 364 additions and 71 deletions
|
@ -8,10 +8,18 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
before_action :set_list, except: [:index, :create]
|
before_action :set_list, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@lists = List.where(account: current_account).all
|
@lists = load_lists.all
|
||||||
render json: @lists, each_serializer: REST::ListSerializer
|
render json: @lists, each_serializer: REST::ListSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_lists
|
||||||
|
if list_params[:favourite]
|
||||||
|
List.where(account: current_account).where(favourite: true)
|
||||||
|
else
|
||||||
|
List.where(account: current_account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @list, serializer: REST::ListSerializer
|
render json: @list, serializer: REST::ListSerializer
|
||||||
end
|
end
|
||||||
|
@ -31,6 +39,16 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
render_empty
|
render_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def favourite
|
||||||
|
@list.favourite!
|
||||||
|
render json: @list, serializer: REST::ListSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
def unfavourite
|
||||||
|
@list.unfavourite!
|
||||||
|
render json: @list, serializer: REST::ListSerializer
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_list
|
def set_list
|
||||||
|
@ -38,6 +56,6 @@ class Api::V1::ListsController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_params
|
def list_params
|
||||||
params.permit(:title, :replies_policy)
|
params.permit(:title, :replies_policy, :favourite)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,6 +26,14 @@ export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
|
||||||
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
|
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
|
||||||
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
|
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
|
||||||
|
|
||||||
|
export const LIST_FAVOURITE_REQUEST = 'LIST_FAVOURITE_REQUEST';
|
||||||
|
export const LIST_FAVOURITE_SUCCESS = 'LIST_FAVOURITE_SUCCESS';
|
||||||
|
export const LIST_FAVOURITE_FAIL = 'LIST_FAVOURITE_FAIL';
|
||||||
|
|
||||||
|
export const LIST_UNFAVOURITE_REQUEST = 'LIST_UNFAVOURITE_REQUEST';
|
||||||
|
export const LIST_UNFAVOURITE_SUCCESS = 'LIST_UNFAVOURITE_SUCCESS';
|
||||||
|
export const LIST_UNFAVOURITE_FAIL = 'LIST_UNFAVOURITE_FAIL';
|
||||||
|
|
||||||
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
|
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
|
||||||
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
|
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
|
||||||
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
|
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
|
||||||
|
@ -206,6 +214,54 @@ export const deleteListFail = (id, error) => ({
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const favouriteList = id => (dispatch, getState) => {
|
||||||
|
dispatch(favouriteListRequest(id));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/lists/${id}/favourite`)
|
||||||
|
.then(({ data }) => dispatch(favouriteListSuccess(data)))
|
||||||
|
.catch(err => dispatch(favouriteListFail(id, err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const favouriteListRequest = id => ({
|
||||||
|
type: LIST_FAVOURITE_REQUEST,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const favouriteListSuccess = list => ({
|
||||||
|
type: LIST_FAVOURITE_SUCCESS,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const favouriteListFail = (id, error) => ({
|
||||||
|
type: LIST_FAVOURITE_FAIL,
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unfavouriteList = id => (dispatch, getState) => {
|
||||||
|
dispatch(unfavouriteListRequest(id));
|
||||||
|
|
||||||
|
api(getState).post(`/api/v1/lists/${id}/unfavourite`)
|
||||||
|
.then(({ data }) => dispatch(unfavouriteListSuccess(data)))
|
||||||
|
.catch(err => dispatch(unfavouriteListFail(id, err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unfavouriteListRequest = id => ({
|
||||||
|
type: LIST_UNFAVOURITE_REQUEST,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unfavouriteListSuccess = list => ({
|
||||||
|
type: LIST_UNFAVOURITE_SUCCESS,
|
||||||
|
list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unfavouriteListFail = (id, error) => ({
|
||||||
|
type: LIST_UNFAVOURITE_FAIL,
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
export const fetchListAccounts = listId => (dispatch, getState) => {
|
export const fetchListAccounts = listId => (dispatch, getState) => {
|
||||||
dispatch(fetchListAccountsRequest(listId));
|
dispatch(fetchListAccountsRequest(listId));
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Iterable, fromJS } from 'immutable';
|
import { Iterable, fromJS } from 'immutable';
|
||||||
import { hydrateCompose } from './compose';
|
import { hydrateCompose } from './compose';
|
||||||
import { importFetchedAccounts } from './importer';
|
import { importFetchedAccounts } from './importer';
|
||||||
|
import { fetchListsSuccess } from './lists';
|
||||||
|
|
||||||
export const STORE_HYDRATE = 'STORE_HYDRATE';
|
export const STORE_HYDRATE = 'STORE_HYDRATE';
|
||||||
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
||||||
|
@ -20,5 +21,6 @@ export function hydrateStore(rawState) {
|
||||||
|
|
||||||
dispatch(hydrateCompose());
|
dispatch(hydrateCompose());
|
||||||
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
|
||||||
|
dispatch(fetchListsSuccess(Object.values(rawState.lists)));
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -17,6 +17,11 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||||
cachedHeight: PropTypes.number,
|
cachedHeight: PropTypes.number,
|
||||||
onHeightChange: PropTypes.func,
|
onHeightChange: PropTypes.func,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
tabIndex: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
tabIndex: '0',
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -102,7 +107,7 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, id, index, listLength, cachedHeight } = this.props;
|
const { children, id, index, listLength, cachedHeight, tabIndex } = this.props;
|
||||||
const { isIntersecting, isHidden } = this.state;
|
const { isIntersecting, isHidden } = this.state;
|
||||||
|
|
||||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||||
|
@ -113,7 +118,7 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||||
aria-setsize={listLength}
|
aria-setsize={listLength}
|
||||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||||
data-id={id}
|
data-id={id}
|
||||||
tabIndex='0'
|
tabIndex={tabIndex}
|
||||||
>
|
>
|
||||||
{children && React.cloneElement(children, { hidden: true })}
|
{children && React.cloneElement(children, { hidden: true })}
|
||||||
</article>
|
</article>
|
||||||
|
@ -121,7 +126,7 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex='0'>
|
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex={tabIndex}>
|
||||||
{children && React.cloneElement(children, { hidden: false })}
|
{children && React.cloneElement(children, { hidden: false })}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
|
@ -45,10 +45,12 @@ class ScrollableList extends PureComponent {
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
bindToDocument: PropTypes.bool,
|
bindToDocument: PropTypes.bool,
|
||||||
preventScroll: PropTypes.bool,
|
preventScroll: PropTypes.bool,
|
||||||
|
tabIndex: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
|
tabIndex: '0',
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -289,7 +291,7 @@ class ScrollableList extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
|
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore, tabIndex } = this.props;
|
||||||
const { fullscreen } = this.state;
|
const { fullscreen } = this.state;
|
||||||
const childrenCount = React.Children.count(children);
|
const childrenCount = React.Children.count(children);
|
||||||
|
|
||||||
|
@ -325,6 +327,7 @@ class ScrollableList extends PureComponent {
|
||||||
listLength={childrenCount}
|
listLength={childrenCount}
|
||||||
intersectionObserverWrapper={this.intersectionObserverWrapper}
|
intersectionObserverWrapper={this.intersectionObserverWrapper}
|
||||||
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
|
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
|
||||||
|
tabIndex={tabIndex}
|
||||||
>
|
>
|
||||||
{React.cloneElement(child, {
|
{React.cloneElement(child, {
|
||||||
getScrollPosition: this.getScrollPosition,
|
getScrollPosition: this.getScrollPosition,
|
||||||
|
|
|
@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { me, profile_directory, showTrends, enable_limited_timeline } from '../../initial_state';
|
import { me, profile_directory, showTrends, enable_limited_timeline } from '../../initial_state';
|
||||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||||
import { fetchLists } from 'mastodon/actions/lists';
|
|
||||||
import { fetchFavouriteDomains } from 'mastodon/actions/favourite_domains';
|
import { fetchFavouriteDomains } from 'mastodon/actions/favourite_domains';
|
||||||
import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags';
|
import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
@ -49,7 +48,7 @@ const messages = defineMessages({
|
||||||
trends: { id: 'navigation_bar.trends', defaultMessage: 'Trends' },
|
trends: { id: 'navigation_bar.trends', defaultMessage: 'Trends' },
|
||||||
information_acct: { id: 'navigation_bar.information_acct', defaultMessage: 'Fedibird info' },
|
information_acct: { id: 'navigation_bar.information_acct', defaultMessage: 'Fedibird info' },
|
||||||
hashtag_fedibird: { id: 'navigation_bar.hashtag_fedibird', defaultMessage: 'fedibird' },
|
hashtag_fedibird: { id: 'navigation_bar.hashtag_fedibird', defaultMessage: 'fedibird' },
|
||||||
lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' },
|
lists_subheading: { id: 'column_subheading.favourite_lists', defaultMessage: 'Favourite Lists' },
|
||||||
favourite_domains_subheading: { id: 'column_subheading.favourite_domains', defaultMessage: 'Favourite domains' },
|
favourite_domains_subheading: { id: 'column_subheading.favourite_domains', defaultMessage: 'Favourite domains' },
|
||||||
favourite_tags_subheading: { id: 'column_subheading.favourite_tags', defaultMessage: 'Favourite tags' },
|
favourite_tags_subheading: { id: 'column_subheading.favourite_tags', defaultMessage: 'Favourite tags' },
|
||||||
});
|
});
|
||||||
|
@ -65,7 +64,6 @@ const mapStateToProps = state => ({
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
|
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
|
||||||
fetchLists: () => dispatch(fetchLists()),
|
|
||||||
fetchFavouriteDomains: () => dispatch(fetchFavouriteDomains()),
|
fetchFavouriteDomains: () => dispatch(fetchFavouriteDomains()),
|
||||||
fetchFavouriteTags: () => dispatch(fetchFavouriteTags()),
|
fetchFavouriteTags: () => dispatch(fetchFavouriteTags()),
|
||||||
});
|
});
|
||||||
|
@ -96,7 +94,6 @@ class GettingStarted extends ImmutablePureComponent {
|
||||||
columns: ImmutablePropTypes.list,
|
columns: ImmutablePropTypes.list,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
fetchFollowRequests: PropTypes.func.isRequired,
|
fetchFollowRequests: PropTypes.func.isRequired,
|
||||||
fetchLists: PropTypes.func.isRequired,
|
|
||||||
fetchFavouriteDomains: PropTypes.func.isRequired,
|
fetchFavouriteDomains: PropTypes.func.isRequired,
|
||||||
fetchFavouriteTags: PropTypes.func.isRequired,
|
fetchFavouriteTags: PropTypes.func.isRequired,
|
||||||
unreadFollowRequests: PropTypes.number,
|
unreadFollowRequests: PropTypes.number,
|
||||||
|
@ -107,7 +104,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
const { fetchFollowRequests, fetchLists, fetchFavouriteDomains, fetchFavouriteTags, multiColumn } = this.props;
|
const { fetchFollowRequests, fetchFavouriteDomains, fetchFavouriteTags, multiColumn } = this.props;
|
||||||
|
|
||||||
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
|
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
|
||||||
this.context.router.history.replace('/timelines/home');
|
this.context.router.history.replace('/timelines/home');
|
||||||
|
@ -115,7 +112,6 @@ class GettingStarted extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchFollowRequests();
|
fetchFollowRequests();
|
||||||
fetchLists();
|
|
||||||
fetchFavouriteDomains();
|
fetchFavouriteDomains();
|
||||||
fetchFavouriteTags();
|
fetchFavouriteTags();
|
||||||
}
|
}
|
||||||
|
@ -220,7 +216,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||||
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
|
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
|
||||||
);
|
);
|
||||||
|
|
||||||
height += 48*5;
|
height += 48*6;
|
||||||
|
|
||||||
if (lists && !lists.isEmpty()) {
|
if (lists && !lists.isEmpty()) {
|
||||||
navItems.push(<ColumnSubheading key='header-lists' text={intl.formatMessage(messages.lists_subheading)} />);
|
navItems.push(<ColumnSubheading key='header-lists' text={intl.formatMessage(messages.lists_subheading)} />);
|
||||||
|
|
79
app/javascript/mastodon/features/lists/components/list.js
Normal file
79
app/javascript/mastodon/features/lists/components/list.js
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import Icon from '../../../components/icon';
|
||||||
|
import ColumnLink from 'mastodon/features/ui/components/column_link';
|
||||||
|
import { openModal } from '../../../actions/modal';
|
||||||
|
import { favouriteList, unfavouriteList } from '../../../actions/lists';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
favourite: { id: 'lists.favourite', defaultMessage: 'Favourite list' },
|
||||||
|
edit: { id: 'lists.edit', defaultMessage: 'Edit list' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect()
|
||||||
|
@injectIntl
|
||||||
|
class List extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
text: PropTypes.string.isRequired,
|
||||||
|
favourite: PropTypes.bool.isRequired,
|
||||||
|
animate: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
animate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
activate: false,
|
||||||
|
deactivate: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (nextProps) {
|
||||||
|
if (!nextProps.animate) return;
|
||||||
|
|
||||||
|
if (this.props.favourite && !nextProps.favourite) {
|
||||||
|
this.setState({ activate: false, deactivate: true });
|
||||||
|
} else if (!this.props.favourite && nextProps.favourite) {
|
||||||
|
this.setState({ activate: true, deactivate: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditClick = () => {
|
||||||
|
this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFavouriteClick = () => {
|
||||||
|
const { id, favourite, dispatch } = this.props;
|
||||||
|
|
||||||
|
if (favourite) {
|
||||||
|
dispatch(unfavouriteList(id));
|
||||||
|
} else {
|
||||||
|
dispatch(favouriteList(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { id, text, favourite, intl } = this.props;
|
||||||
|
const { activate, deactivate } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='list-link'>
|
||||||
|
<div className='list-name'><ColumnLink to={`/timelines/list/${id}`} icon='list-ul' text={text} /></div>
|
||||||
|
<button className={classNames('list-favourite-button icon-button star-icon', {active: favourite, pressed: favourite, activate, deactivate})} title={intl.formatMessage(messages.favourite)} onClick={this.handleFavouriteClick}>
|
||||||
|
<Icon id='star' className='column-link__icon' fixedWidth />
|
||||||
|
</button>
|
||||||
|
<button className='list-edit-button' title={intl.formatMessage(messages.edit)} onClick={this.handleEditClick}>
|
||||||
|
<Icon id='pencil' className='column-link__icon' fixedWidth />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,9 +8,9 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
import { fetchLists } from '../../actions/lists';
|
import { fetchLists } from '../../actions/lists';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import ColumnLink from '../ui/components/column_link';
|
|
||||||
import ColumnSubheading from '../ui/components/column_subheading';
|
import ColumnSubheading from '../ui/components/column_subheading';
|
||||||
import NewListForm from './components/new_list_form';
|
import NewListForm from './components/new_list_form';
|
||||||
|
import List from './components/list';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
import ScrollableList from '../../components/scrollable_list';
|
||||||
|
|
||||||
|
@ -71,9 +71,10 @@ class Lists extends ImmutablePureComponent {
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
|
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
|
tabIndex='-1'
|
||||||
>
|
>
|
||||||
{lists.map(list =>
|
{lists.map(list =>
|
||||||
<ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
|
<List key={list.get('id')} id={list.get('id')} text={list.get('title')} favourite={list.get('favourite')} animate />
|
||||||
)}
|
)}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -83,6 +83,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
isModalOpen: PropTypes.bool.isRequired,
|
isModalOpen: PropTypes.bool.isRequired,
|
||||||
singleColumn: PropTypes.bool,
|
singleColumn: PropTypes.bool,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
|
favouriteLists: ImmutablePropTypes.list,
|
||||||
links: PropTypes.node,
|
links: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -95,7 +96,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps() {
|
componentWillReceiveProps() {
|
||||||
if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getSwipeableIndex(this.context.router.history.location.pathname)) {
|
if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname)) {
|
||||||
this.setState({ shouldAnimate: false });
|
this.setState({ shouldAnimate: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -116,7 +117,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
this.setState({ renderComposePanel: !this.mediaQuery.matches });
|
this.setState({ renderComposePanel: !this.mediaQuery.matches });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastIndex = getSwipeableIndex(this.context.router.history.location.pathname);
|
this.lastIndex = getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname);
|
||||||
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
|
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
|
||||||
|
|
||||||
this.setState({ shouldAnimate: true });
|
this.setState({ shouldAnimate: true });
|
||||||
|
@ -141,7 +142,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = getSwipeableIndex(this.context.router.history.location.pathname);
|
const newIndex = getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname);
|
||||||
|
|
||||||
if (this.lastIndex !== newIndex) {
|
if (this.lastIndex !== newIndex) {
|
||||||
this.lastIndex = newIndex;
|
this.lastIndex = newIndex;
|
||||||
|
@ -187,14 +188,14 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
document.querySelector(nextLinkSelector).classList.add('active');
|
document.querySelector(nextLinkSelector).classList.add('active');
|
||||||
|
|
||||||
if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
|
if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
|
||||||
this.context.router.history.push(getSwipeableLink(this.pendingIndex));
|
this.context.router.history.push(getSwipeableLink(this.props.favouriteLists, this.pendingIndex));
|
||||||
this.pendingIndex = null;
|
this.pendingIndex = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAnimationEnd = () => {
|
handleAnimationEnd = () => {
|
||||||
if (typeof this.pendingIndex === 'number') {
|
if (typeof this.pendingIndex === 'number') {
|
||||||
this.context.router.history.push(getSwipeableLink(this.pendingIndex));
|
this.context.router.history.push(getSwipeableLink(this.props.favouriteLists, this.pendingIndex));
|
||||||
this.pendingIndex = null;
|
this.pendingIndex = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -212,8 +213,8 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderView = (link, index) => {
|
renderView = (link, index) => {
|
||||||
const columnIndex = getSwipeableIndex(this.context.router.history.location.pathname);
|
const columnIndex = getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname);
|
||||||
const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
|
const title = link.props['data-preview-title'] ?? this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
|
||||||
const icon = link.props['data-preview-icon'];
|
const icon = link.props['data-preview-icon'];
|
||||||
|
|
||||||
const view = (index === columnIndex) ?
|
const view = (index === columnIndex) ?
|
||||||
|
@ -239,7 +240,7 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
const { columns, children, singleColumn, isModalOpen, links, intl } = this.props;
|
const { columns, children, singleColumn, isModalOpen, links, intl } = this.props;
|
||||||
const { shouldAnimate, renderComposePanel } = this.state;
|
const { shouldAnimate, renderComposePanel } = this.state;
|
||||||
|
|
||||||
const columnIndex = getSwipeableIndex(this.context.router.history.location.pathname);
|
const columnIndex = getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname);
|
||||||
|
|
||||||
if (singleColumn) {
|
if (singleColumn) {
|
||||||
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className={classNames('floating-action-button', { 'bottom-bar': place_tab_bar_at_bottom })} aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
|
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className={classNames('floating-action-button', { 'bottom-bar': place_tab_bar_at_bottom })} aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes, { list } from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { fetchLists } from 'mastodon/actions/lists';
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { NavLink, withRouter } from 'react-router-dom';
|
import { NavLink, withRouter } from 'react-router-dom';
|
||||||
|
@ -13,7 +12,7 @@ export const getOrderedLists = createSelector([state => state.get('lists')], lis
|
||||||
return lists;
|
return lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(10);
|
return lists.toList().filter(item => !!item && item.get('favourite')).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
|
@ -29,11 +28,6 @@ class ListPanel extends ImmutablePureComponent {
|
||||||
lists: ImmutablePropTypes.list,
|
lists: ImmutablePropTypes.list,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(fetchLists());
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { lists } = this.props;
|
const { lists } = this.props;
|
||||||
|
|
||||||
|
|
|
@ -7,57 +7,90 @@ import { isUserTouching } from '../../../is_mobile';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import NotificationsCounterIcon from './notifications_counter_icon';
|
import NotificationsCounterIcon from './notifications_counter_icon';
|
||||||
import { place_tab_bar_at_bottom, show_tab_bar_label, enable_limited_timeline } from 'mastodon/initial_state';
|
import { place_tab_bar_at_bottom, show_tab_bar_label, enable_limited_timeline } from 'mastodon/initial_state';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const links = [
|
const link_home = <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.home' defaultMessage='Home' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.home' defaultMessage='Home' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.home' defaultMessage='Home' /></span></NavLink>,
|
const link_limited = <NavLink className='tabs-bar__link tabs-bar__limited' to='/timelines/limited' data-preview-title-id='column.limited' data-preview-icon='lock' ><Icon id='lock' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.limited_timeline' defaultMessage='Limited' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.limited_timeline' defaultMessage='Ltd.' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link tabs-bar__limited' to='/timelines/limited' data-preview-title-id='column.limited' data-preview-icon='lock' ><Icon id='lock' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.limited_timeline' defaultMessage='Limited' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.limited_timeline' defaultMessage='Ltd.' /></span></NavLink>,
|
const link_notifications = <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.notifications' defaultMessage='Notif.' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.notifications' defaultMessage='Notif.' /></span></NavLink>,
|
const link_public = <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.public_timeline' defaultMessage='FTL' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.public_timeline' defaultMessage='FTL' /></span></NavLink>,
|
// const link_lists = <NavLink className='tabs-bar__link' exact to='/lists' data-preview-title-id='column.lists' data-preview-icon='list-ul' ><Icon id='list-ul' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.lists' defaultMessage='Lists' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.lists' defaultMessage='List' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link' exact to='/lists' data-preview-title-id='column.lists' data-preview-icon='list-ul' ><Icon id='list-ul' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.lists' defaultMessage='Lists' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.lists' defaultMessage='List' /></span></NavLink>,
|
const link_search = <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.search' defaultMessage='Search' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.search' defaultMessage='Search' children={msg=> <>{msg}</>} /></span></NavLink>;
|
||||||
<NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.search' defaultMessage='Search' /></span></NavLink>,
|
// const link_preferences = <a className='tabs-bar__external-link' href='/settings/preferences' to='/settings/preferences' data-preview-title-id='navigation_bar.preferences' data-preview-icon='cog' ><Icon id='cog' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.preferences' defaultMessage='Pref.' children={msg=> <>{msg}</>} /></span></a>;
|
||||||
// <a className='tabs-bar__external-link' href='/settings/preferences' to='/settings/preferences' data-preview-title-id='navigation_bar.preferences' data-preview-icon='cog' ><Icon id='cog' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.preferences' defaultMessage='Pref.' /></span></a>,
|
// const link_sign_out = <a className='tabs-bar__external-link' href='/auth/sign_out' to='/auth/sign_out' data-method='delete' data-preview-title-id='navigation_bar.logout' data-preview-icon='sign-out' ><Icon id='sign-out' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.logout' defaultMessage='Logout' children={msg=> <>{msg}</>} /></span></a>;
|
||||||
// <a className='tabs-bar__external-link' href='/auth/sign_out' to='/auth/sign_out' data-method='delete' data-preview-title-id='navigation_bar.logout' data-preview-icon='sign-out' ><Icon id='sign-out' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.logout' defaultMessage='Logout' /></span></a>,
|
const link_started = <NavLink className='tabs-bar__link hamburger' to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>;
|
||||||
<NavLink className='tabs-bar__link hamburger' to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getLinks = memoize(() => {
|
export const getLinks = memoize((favouriteLists = null) => {
|
||||||
return links.filter(link => {
|
const link_favourite_lists = favouriteLists ? favouriteLists.map(list => {
|
||||||
const classes = link.props.className.split(/\s+/);
|
if (!list.get('favourite')) {
|
||||||
return !(!enable_limited_timeline && classes.includes('tabs-bar__limited'));
|
return null;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink className='tabs-bar__link' exact to={`/timelines/list/${list.get('id')}`} data-preview-title={list.get('title')} data-preview-title-id={`list/${list.get('id')}`} data-preview-icon='list-ul' >
|
||||||
|
<Icon id='list-ul' fixedWidth /><span className='tabs-bar__link__full-label'>{list.get('title')}</span>
|
||||||
|
<span className='tabs-bar__link__short-label'>{list.get('title')}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}).toArray() : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
link_home,
|
||||||
|
enable_limited_timeline ? link_limited : null,
|
||||||
|
link_favourite_lists,
|
||||||
|
link_notifications,
|
||||||
|
link_public,
|
||||||
|
link_search,
|
||||||
|
link_started,
|
||||||
|
].flat().filter(v => !!v);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getSwipeableLinks = memoize(() => {
|
export const getSwipeableLinks = memoize((favouriteLists = null) => {
|
||||||
return getLinks().filter(link => {
|
return getLinks(favouriteLists).filter(link => {
|
||||||
const classes = link.props.className.split(/\s+/);
|
const classes = link.props.className.split(/\s+/);
|
||||||
return classes.includes('tabs-bar__link');
|
return classes.includes('tabs-bar__link');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export function getSwipeableIndex (path) {
|
export function getSwipeableIndex (favouriteLists = null, path) {
|
||||||
return getSwipeableLinks().findIndex(link => link.props.to === path);
|
return getSwipeableLinks(favouriteLists).findIndex(link => link.props.to === path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSwipeableLink (index) {
|
export function getSwipeableLink (favouriteLists = null, index) {
|
||||||
return getSwipeableLinks()[index].props.to;
|
return getSwipeableLinks(favouriteLists)[index].props.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIndex (path) {
|
export function getIndex (favouriteLists = null, path) {
|
||||||
return getLinks().findIndex(link => link.props.to === path);
|
return getLinks(favouriteLists).findIndex(link => link.props.to === path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLink (index) {
|
export function getLink (favouriteLists = null, index) {
|
||||||
return getLinks()[index].props.to;
|
return getLinks(favouriteLists)[index].props.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getFavouriteOrderedLists = createSelector([state => state.get('lists')], lists => {
|
||||||
|
if (!lists) {
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lists.toList().filter(item => !!item && item.get('favourite')).sort((a, b) => a.get('title').localeCompare(b.get('title')));
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
favouriteLists: getFavouriteOrderedLists(state),
|
||||||
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default @injectIntl
|
||||||
@withRouter
|
@withRouter
|
||||||
|
@connect(mapStateToProps)
|
||||||
class TabsBar extends React.PureComponent {
|
class TabsBar extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
history: PropTypes.object.isRequired,
|
history: PropTypes.object.isRequired,
|
||||||
|
favouriteLists: ImmutablePropTypes.list,
|
||||||
};
|
};
|
||||||
|
|
||||||
setRef = ref => {
|
setRef = ref => {
|
||||||
|
@ -65,6 +98,8 @@ class TabsBar extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
|
const { favouriteLists } = this.props;
|
||||||
|
|
||||||
// Only apply optimization for touch devices, which we assume are slower
|
// Only apply optimization for touch devices, which we assume are slower
|
||||||
// We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
|
// We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
|
||||||
if (isUserTouching()) {
|
if (isUserTouching()) {
|
||||||
|
@ -75,7 +110,7 @@ class TabsBar extends React.PureComponent {
|
||||||
const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
|
const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
|
||||||
const currentTab = tabs.find(tab => tab.classList.contains('active'));
|
const currentTab = tabs.find(tab => tab.classList.contains('active'));
|
||||||
const nextTab = tabs.find(tab => tab.contains(e.target));
|
const nextTab = tabs.find(tab => tab.contains(e.target));
|
||||||
const { props: { to } } = getLinks()[Array(...this.node.childNodes).indexOf(nextTab)];
|
const { props: { to } } = getLinks(favouriteLists)[Array(...this.node.childNodes).indexOf(nextTab)];
|
||||||
|
|
||||||
|
|
||||||
if (currentTab !== nextTab) {
|
if (currentTab !== nextTab) {
|
||||||
|
@ -97,12 +132,12 @@ class TabsBar extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl: { formatMessage } } = this.props;
|
const { intl: { formatMessage }, favouriteLists } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='tabs-bar__wrapper'>
|
<div className='tabs-bar__wrapper'>
|
||||||
<nav className={classNames('tabs-bar', { 'bottom-bar': place_tab_bar_at_bottom })} ref={this.setRef}>
|
<nav className={classNames('tabs-bar', { 'bottom-bar': place_tab_bar_at_bottom })} ref={this.setRef}>
|
||||||
{getLinks().map(link => React.cloneElement(link, { key: link.props.to, className: classNames(link.props.className, { 'short-label': show_tab_bar_label }), onClick: link.props.href ? null : this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
|
{getLinks(favouriteLists).map(link => React.cloneElement(link, { key: link.props.to, className: classNames(link.props.className, { 'short-label': show_tab_bar_label }), onClick: link.props.href ? null : this.handleClick, 'aria-label': link.props['data-preview-title'] ?? formatMessage({ id: link.props['data-preview-title-id'] }) }))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id='tabs-bar__portal' />
|
<div id='tabs-bar__portal' />
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import ColumnsArea from '../components/columns_area';
|
import ColumnsArea from '../components/columns_area';
|
||||||
import { getSwipeableLinks } from '../components/tabs_bar';
|
import { getSwipeableLinks, getFavouriteOrderedLists } from '../components/tabs_bar';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => {
|
||||||
|
const favouriteLists = getFavouriteOrderedLists(state)
|
||||||
|
|
||||||
|
return {
|
||||||
columns: state.getIn(['settings', 'columns']),
|
columns: state.getIn(['settings', 'columns']),
|
||||||
isModalOpen: !!state.get('modal').modalType,
|
isModalOpen: !!state.get('modal').modalType,
|
||||||
links: getSwipeableLinks(),
|
favouriteLists: favouriteLists,
|
||||||
});
|
links: getSwipeableLinks(favouriteLists),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, null, null, { forwardRef: true })(ColumnsArea);
|
export default connect(mapStateToProps, null, null, { forwardRef: true })(ColumnsArea);
|
||||||
|
|
|
@ -109,8 +109,8 @@
|
||||||
"column_header.show_settings": "Show settings",
|
"column_header.show_settings": "Show settings",
|
||||||
"column_header.unpin": "Unpin",
|
"column_header.unpin": "Unpin",
|
||||||
"column_subheading.favourite_domains": "Favourite domains",
|
"column_subheading.favourite_domains": "Favourite domains",
|
||||||
|
"column_subheading.favourite_lists": "Favourite lists",
|
||||||
"column_subheading.favourite_tags": "Favourite tags",
|
"column_subheading.favourite_tags": "Favourite tags",
|
||||||
"column_subheading.lists": "Lists",
|
|
||||||
"column_subheading.settings": "Settings",
|
"column_subheading.settings": "Settings",
|
||||||
"community.column_settings.local_only": "Local only",
|
"community.column_settings.local_only": "Local only",
|
||||||
"community.column_settings.media_only": "Media Only",
|
"community.column_settings.media_only": "Media Only",
|
||||||
|
@ -315,6 +315,7 @@
|
||||||
"lists.delete": "Delete list",
|
"lists.delete": "Delete list",
|
||||||
"lists.edit": "Edit list",
|
"lists.edit": "Edit list",
|
||||||
"lists.edit.submit": "Change title",
|
"lists.edit.submit": "Change title",
|
||||||
|
"lists.favourite": "Favourite list",
|
||||||
"lists.new.create": "Add list",
|
"lists.new.create": "Add list",
|
||||||
"lists.new.title_placeholder": "New list title",
|
"lists.new.title_placeholder": "New list title",
|
||||||
"lists.replies_policy.followed": "Any followed user",
|
"lists.replies_policy.followed": "Any followed user",
|
||||||
|
|
|
@ -109,8 +109,8 @@
|
||||||
"column_header.show_settings": "設定を表示",
|
"column_header.show_settings": "設定を表示",
|
||||||
"column_header.unpin": "ピン留めを外す",
|
"column_header.unpin": "ピン留めを外す",
|
||||||
"column_subheading.favourite_domains": "お気に入りドメイン",
|
"column_subheading.favourite_domains": "お気に入りドメイン",
|
||||||
|
"column_subheading.favourite_lists": "お気に入りリスト",
|
||||||
"column_subheading.favourite_tags": "お気に入りハッシュタグ",
|
"column_subheading.favourite_tags": "お気に入りハッシュタグ",
|
||||||
"column_subheading.lists": "リスト",
|
|
||||||
"column_subheading.settings": "設定",
|
"column_subheading.settings": "設定",
|
||||||
"community.column_settings.local_only": "ローカルのみ表示",
|
"community.column_settings.local_only": "ローカルのみ表示",
|
||||||
"community.column_settings.media_only": "メディアのみ表示",
|
"community.column_settings.media_only": "メディアのみ表示",
|
||||||
|
@ -316,6 +316,7 @@
|
||||||
"lists.delete": "リストを削除",
|
"lists.delete": "リストを削除",
|
||||||
"lists.edit": "リストを編集",
|
"lists.edit": "リストを編集",
|
||||||
"lists.edit.submit": "タイトルを変更",
|
"lists.edit.submit": "タイトルを変更",
|
||||||
|
"lists.favourite": "リストをお気に入り",
|
||||||
"lists.new.create": "リストを作成",
|
"lists.new.create": "リストを作成",
|
||||||
"lists.new.title_placeholder": "新規リスト名",
|
"lists.new.title_placeholder": "新規リスト名",
|
||||||
"lists.replies_policy.followed": "フォロー中のユーザー全員",
|
"lists.replies_policy.followed": "フォロー中のユーザー全員",
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
LIST_CREATE_SUCCESS,
|
LIST_CREATE_SUCCESS,
|
||||||
LIST_UPDATE_SUCCESS,
|
LIST_UPDATE_SUCCESS,
|
||||||
LIST_DELETE_SUCCESS,
|
LIST_DELETE_SUCCESS,
|
||||||
|
LIST_FAVOURITE_SUCCESS,
|
||||||
|
LIST_UNFAVOURITE_SUCCESS,
|
||||||
} from '../actions/lists';
|
} from '../actions/lists';
|
||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||||
|
|
||||||
|
@ -25,6 +27,8 @@ export default function lists(state = initialState, action) {
|
||||||
case LIST_FETCH_SUCCESS:
|
case LIST_FETCH_SUCCESS:
|
||||||
case LIST_CREATE_SUCCESS:
|
case LIST_CREATE_SUCCESS:
|
||||||
case LIST_UPDATE_SUCCESS:
|
case LIST_UPDATE_SUCCESS:
|
||||||
|
case LIST_FAVOURITE_SUCCESS:
|
||||||
|
case LIST_UNFAVOURITE_SUCCESS:
|
||||||
return normalizeList(state, action.list);
|
return normalizeList(state, action.list);
|
||||||
case LISTS_FETCH_SUCCESS:
|
case LISTS_FETCH_SUCCESS:
|
||||||
return normalizeLists(state, action.lists);
|
return normalizeLists(state, action.lists);
|
||||||
|
|
|
@ -121,6 +121,8 @@ html {
|
||||||
.getting-started,
|
.getting-started,
|
||||||
.scrollable {
|
.scrollable {
|
||||||
.column-link,
|
.column-link,
|
||||||
|
.list-favourite-button,
|
||||||
|
.list-edit-button,
|
||||||
.circle-edit-button,
|
.circle-edit-button,
|
||||||
.circle-delete-button {
|
.circle-delete-button {
|
||||||
background: $white;
|
background: $white;
|
||||||
|
|
|
@ -6828,6 +6828,48 @@ noscript {
|
||||||
background: rgba($base-overlay-background, 0.5);
|
background: rgba($base-overlay-background, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-link {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.list-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-favourite-button,
|
||||||
|
.list-edit-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-name,
|
||||||
|
.list-favourite-button,
|
||||||
|
.list-edit-button {
|
||||||
|
background: lighten($ui-base-color, 8%);
|
||||||
|
color: $primary-text-color;
|
||||||
|
// padding: 15px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
background: lighten($ui-base-color, 11%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.list-editor {
|
.list-editor {
|
||||||
background: $ui-base-color;
|
background: $ui-base-color;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
@ -486,6 +486,12 @@ body.rtl {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-link .list-name,
|
||||||
|
.list-link .list-favourite-button,
|
||||||
|
.list-link .list-edit-button {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.circle-dropdown .circle-dropdown__menu {
|
.circle-dropdown .circle-dropdown__menu {
|
||||||
background-position: left 8px center;
|
background-position: left 8px center;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# replies_policy :integer default("list"), not null
|
# replies_policy :integer default("list"), not null
|
||||||
|
# favourite :boolean default(FALSE), not null
|
||||||
#
|
#
|
||||||
|
|
||||||
class List < ApplicationRecord
|
class List < ApplicationRecord
|
||||||
|
@ -40,6 +41,20 @@ class List < ApplicationRecord
|
||||||
|
|
||||||
before_destroy :clean_feed_manager
|
before_destroy :clean_feed_manager
|
||||||
|
|
||||||
|
scope :favourites, -> { where(favourite: true) }
|
||||||
|
|
||||||
|
def favourite?
|
||||||
|
favourite
|
||||||
|
end
|
||||||
|
|
||||||
|
def favourite!
|
||||||
|
update!(favourite: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unfavourite!
|
||||||
|
update!(favourite: false)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def clean_feed_manager
|
def clean_feed_manager
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class InitialStateSerializer < ActiveModel::Serializer
|
class InitialStateSerializer < ActiveModel::Serializer
|
||||||
attributes :meta, :compose, :accounts,
|
attributes :meta, :compose, :accounts, :lists,
|
||||||
:media_attachments, :settings
|
:media_attachments, :settings
|
||||||
|
|
||||||
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
|
||||||
|
@ -87,6 +87,11 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||||
store
|
store
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def lists
|
||||||
|
store = ActiveModelSerializers::SerializableResource.new(object.current_account.owned_lists, each_serializer: REST::ListSerializer) if object.current_account
|
||||||
|
store
|
||||||
|
end
|
||||||
|
|
||||||
def media_attachments
|
def media_attachments
|
||||||
{ accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
|
{ accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
|
||||||
end
|
end
|
||||||
|
|
|
@ -105,6 +105,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||||
[
|
[
|
||||||
:favourite_hashtag,
|
:favourite_hashtag,
|
||||||
:favourite_domain,
|
:favourite_domain,
|
||||||
|
:favourite_list,
|
||||||
:status_expire,
|
:status_expire,
|
||||||
:follow_no_delivery,
|
:follow_no_delivery,
|
||||||
:follow_hashtag,
|
:follow_hashtag,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class REST::ListSerializer < ActiveModel::Serializer
|
class REST::ListSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :title, :replies_policy
|
attributes :id, :title, :replies_policy, :favourite
|
||||||
|
|
||||||
def id
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
|
|
|
@ -493,6 +493,11 @@ Rails.application.routes.draw do
|
||||||
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
resources :lists, only: [:index, :create, :show, :update, :destroy] do
|
||||||
resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
|
resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
|
||||||
resource :subscribes, only: [:show, :create, :destroy], controller: 'lists/subscribes'
|
resource :subscribes, only: [:show, :create, :destroy], controller: 'lists/subscribes'
|
||||||
|
|
||||||
|
member do
|
||||||
|
post :favourite
|
||||||
|
post :unfavourite
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :circles, only: [:index, :create, :show, :update, :destroy] do
|
resources :circles, only: [:index, :create, :show, :update, :destroy] do
|
||||||
|
|
15
db/migrate/20210621035518_add_favourite_to_lists.rb
Normal file
15
db/migrate/20210621035518_add_favourite_to_lists.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
|
||||||
|
|
||||||
|
class AddFavouriteToLists < ActiveRecord::Migration[6.1]
|
||||||
|
include Mastodon::MigrationHelpers
|
||||||
|
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def up
|
||||||
|
safety_assured { add_column_with_default :lists, :favourite, :boolean, default: false, allow_null: false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :lists, :favourite
|
||||||
|
end
|
||||||
|
end
|
|
@ -641,6 +641,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "replies_policy", default: 0, null: false
|
t.integer "replies_policy", default: 0, null: false
|
||||||
|
t.boolean "favourite", default: false, null: false
|
||||||
t.index ["account_id"], name: "index_lists_on_account_id"
|
t.index ["account_id"], name: "index_lists_on_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue