Add favourites list

This commit is contained in:
noellabo 2021-06-22 12:06:40 +09:00
parent b290979e82
commit 33785332f1
25 changed files with 364 additions and 71 deletions

View file

@ -8,10 +8,18 @@ class Api::V1::ListsController < Api::BaseController
before_action :set_list, except: [:index, :create]
def index
@lists = List.where(account: current_account).all
@lists = load_lists.all
render json: @lists, each_serializer: REST::ListSerializer
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
render json: @list, serializer: REST::ListSerializer
end
@ -31,6 +39,16 @@ class Api::V1::ListsController < Api::BaseController
render_empty
end
def favourite
@list.favourite!
render json: @list, serializer: REST::ListSerializer
end
def unfavourite
@list.unfavourite!
render json: @list, serializer: REST::ListSerializer
end
private
def set_list
@ -38,6 +56,6 @@ class Api::V1::ListsController < Api::BaseController
end
def list_params
params.permit(:title, :replies_policy)
params.permit(:title, :replies_policy, :favourite)
end
end

View file

@ -26,6 +26,14 @@ export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
export const LIST_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_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
@ -206,6 +214,54 @@ export const deleteListFail = (id, 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) => {
dispatch(fetchListAccountsRequest(listId));

View file

@ -1,6 +1,7 @@
import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
import { importFetchedAccounts } from './importer';
import { fetchListsSuccess } from './lists';
export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -20,5 +21,6 @@ export function hydrateStore(rawState) {
dispatch(hydrateCompose());
dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
dispatch(fetchListsSuccess(Object.values(rawState.lists)));
};
};

View file

@ -17,6 +17,11 @@ export default class IntersectionObserverArticle extends React.Component {
cachedHeight: PropTypes.number,
onHeightChange: PropTypes.func,
children: PropTypes.node,
tabIndex: PropTypes.string,
};
static defaultProps = {
tabIndex: '0',
};
state = {
@ -102,7 +107,7 @@ export default class IntersectionObserverArticle extends React.Component {
}
render () {
const { children, id, index, listLength, cachedHeight } = this.props;
const { children, id, index, listLength, cachedHeight, tabIndex } = this.props;
const { isIntersecting, isHidden } = this.state;
if (!isIntersecting && (isHidden || cachedHeight)) {
@ -113,7 +118,7 @@ export default class IntersectionObserverArticle extends React.Component {
aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id}
tabIndex='0'
tabIndex={tabIndex}
>
{children && React.cloneElement(children, { hidden: true })}
</article>
@ -121,7 +126,7 @@ export default class IntersectionObserverArticle extends React.Component {
}
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 })}
</article>
);

View file

@ -45,10 +45,12 @@ class ScrollableList extends PureComponent {
children: PropTypes.node,
bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool,
tabIndex: PropTypes.string,
};
static defaultProps = {
trackScroll: true,
tabIndex: '0',
};
state = {
@ -289,7 +291,7 @@ class ScrollableList extends PureComponent {
}
render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore, tabIndex } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);
@ -325,6 +327,7 @@ class ScrollableList extends PureComponent {
listLength={childrenCount}
intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
tabIndex={tabIndex}
>
{React.cloneElement(child, {
getScrollPosition: this.getScrollPosition,

View file

@ -9,7 +9,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, profile_directory, showTrends, enable_limited_timeline } from '../../initial_state';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { fetchLists } from 'mastodon/actions/lists';
import { fetchFavouriteDomains } from 'mastodon/actions/favourite_domains';
import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags';
import { List as ImmutableList } from 'immutable';
@ -49,7 +48,7 @@ const messages = defineMessages({
trends: { id: 'navigation_bar.trends', defaultMessage: 'Trends' },
information_acct: { id: 'navigation_bar.information_acct', defaultMessage: 'Fedibird info' },
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_tags_subheading: { id: 'column_subheading.favourite_tags', defaultMessage: 'Favourite tags' },
});
@ -65,7 +64,6 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
fetchLists: () => dispatch(fetchLists()),
fetchFavouriteDomains: () => dispatch(fetchFavouriteDomains()),
fetchFavouriteTags: () => dispatch(fetchFavouriteTags()),
});
@ -96,7 +94,6 @@ class GettingStarted extends ImmutablePureComponent {
columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
fetchFollowRequests: PropTypes.func.isRequired,
fetchLists: PropTypes.func.isRequired,
fetchFavouriteDomains: PropTypes.func.isRequired,
fetchFavouriteTags: PropTypes.func.isRequired,
unreadFollowRequests: PropTypes.number,
@ -107,7 +104,7 @@ class GettingStarted extends ImmutablePureComponent {
};
componentDidMount () {
const { fetchFollowRequests, fetchLists, fetchFavouriteDomains, fetchFavouriteTags, multiColumn } = this.props;
const { fetchFollowRequests, fetchFavouriteDomains, fetchFavouriteTags, multiColumn } = this.props;
if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
this.context.router.history.replace('/timelines/home');
@ -115,7 +112,6 @@ class GettingStarted extends ImmutablePureComponent {
}
fetchFollowRequests();
fetchLists();
fetchFavouriteDomains();
fetchFavouriteTags();
}
@ -220,7 +216,7 @@ class GettingStarted extends ImmutablePureComponent {
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
);
height += 48*5;
height += 48*6;
if (lists && !lists.isEmpty()) {
navItems.push(<ColumnSubheading key='header-lists' text={intl.formatMessage(messages.lists_subheading)} />);

View 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>
);
}
}

View file

@ -8,9 +8,9 @@ import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { fetchLists } from '../../actions/lists';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import NewListForm from './components/new_list_form';
import List from './components/list';
import { createSelector } from 'reselect';
import ScrollableList from '../../components/scrollable_list';
@ -71,9 +71,10 @@ class Lists extends ImmutablePureComponent {
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
tabIndex='-1'
>
{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>
</Column>

View file

@ -83,6 +83,7 @@ class ColumnsArea extends ImmutablePureComponent {
isModalOpen: PropTypes.bool.isRequired,
singleColumn: PropTypes.bool,
children: PropTypes.node,
favouriteLists: ImmutablePropTypes.list,
links: PropTypes.node,
};
@ -95,7 +96,7 @@ class ColumnsArea extends ImmutablePureComponent {
}
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 });
}
}
@ -116,7 +117,7 @@ class ColumnsArea extends ImmutablePureComponent {
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.setState({ shouldAnimate: true });
@ -141,7 +142,7 @@ class ColumnsArea extends ImmutablePureComponent {
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) {
this.lastIndex = newIndex;
@ -187,14 +188,14 @@ class ColumnsArea extends ImmutablePureComponent {
document.querySelector(nextLinkSelector).classList.add('active');
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;
}
}
handleAnimationEnd = () => {
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;
}
}
@ -212,8 +213,8 @@ class ColumnsArea extends ImmutablePureComponent {
}
renderView = (link, index) => {
const columnIndex = getSwipeableIndex(this.context.router.history.location.pathname);
const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
const columnIndex = getSwipeableIndex(this.props.favouriteLists, this.context.router.history.location.pathname);
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 view = (index === columnIndex) ?
@ -239,7 +240,7 @@ class ColumnsArea extends ImmutablePureComponent {
const { columns, children, singleColumn, isModalOpen, links, intl } = this.props;
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) {
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>;

View file

@ -1,8 +1,7 @@
import React from 'react';
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 { fetchLists } from 'mastodon/actions/lists';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { NavLink, withRouter } from 'react-router-dom';
@ -13,7 +12,7 @@ export const getOrderedLists = createSelector([state => state.get('lists')], lis
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 => ({
@ -29,11 +28,6 @@ class ListPanel extends ImmutablePureComponent {
lists: ImmutablePropTypes.list,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchLists());
}
render () {
const { lists } = this.props;

View file

@ -7,57 +7,90 @@ import { isUserTouching } from '../../../is_mobile';
import Icon from 'mastodon/components/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 ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import classNames from 'classnames';
const links = [
<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>,
<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>,
<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>,
<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>,
<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>,
<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>,
// <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>,
// <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>,
<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>,
];
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>;
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>;
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>;
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>;
// 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>;
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>;
// 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>;
// 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>;
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>;
export const getLinks = memoize(() => {
return links.filter(link => {
const classes = link.props.className.split(/\s+/);
return !(!enable_limited_timeline && classes.includes('tabs-bar__limited'));
});
export const getLinks = memoize((favouriteLists = null) => {
const link_favourite_lists = favouriteLists ? favouriteLists.map(list => {
if (!list.get('favourite')) {
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(() => {
return getLinks().filter(link => {
export const getSwipeableLinks = memoize((favouriteLists = null) => {
return getLinks(favouriteLists).filter(link => {
const classes = link.props.className.split(/\s+/);
return classes.includes('tabs-bar__link');
});
});
export function getSwipeableIndex (path) {
return getSwipeableLinks().findIndex(link => link.props.to === path);
export function getSwipeableIndex (favouriteLists = null, path) {
return getSwipeableLinks(favouriteLists).findIndex(link => link.props.to === path);
}
export function getSwipeableLink (index) {
return getSwipeableLinks()[index].props.to;
export function getSwipeableLink (favouriteLists = null, index) {
return getSwipeableLinks(favouriteLists)[index].props.to;
}
export function getIndex (path) {
return getLinks().findIndex(link => link.props.to === path);
export function getIndex (favouriteLists = null, path) {
return getLinks(favouriteLists).findIndex(link => link.props.to === path);
}
export function getLink (index) {
return getLinks()[index].props.to;
export function getLink (favouriteLists = null, index) {
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
@withRouter
@connect(mapStateToProps)
class TabsBar extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
favouriteLists: ImmutablePropTypes.list,
};
setRef = ref => {
@ -65,6 +98,8 @@ class TabsBar extends React.PureComponent {
}
handleClick = (e) => {
const { favouriteLists } = this.props;
// 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
if (isUserTouching()) {
@ -75,7 +110,7 @@ class TabsBar extends React.PureComponent {
const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
const currentTab = tabs.find(tab => tab.classList.contains('active'));
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) {
@ -97,12 +132,12 @@ class TabsBar extends React.PureComponent {
}
render () {
const { intl: { formatMessage } } = this.props;
const { intl: { formatMessage }, favouriteLists } = this.props;
return (
<div className='tabs-bar__wrapper'>
<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>
<div id='tabs-bar__portal' />

View file

@ -1,11 +1,16 @@
import { connect } from 'react-redux';
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']),
isModalOpen: !!state.get('modal').modalType,
links: getSwipeableLinks(),
});
favouriteLists: favouriteLists,
links: getSwipeableLinks(favouriteLists),
};
};
export default connect(mapStateToProps, null, null, { forwardRef: true })(ColumnsArea);

View file

@ -109,8 +109,8 @@
"column_header.show_settings": "Show settings",
"column_header.unpin": "Unpin",
"column_subheading.favourite_domains": "Favourite domains",
"column_subheading.favourite_lists": "Favourite lists",
"column_subheading.favourite_tags": "Favourite tags",
"column_subheading.lists": "Lists",
"column_subheading.settings": "Settings",
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
@ -315,6 +315,7 @@
"lists.delete": "Delete list",
"lists.edit": "Edit list",
"lists.edit.submit": "Change title",
"lists.favourite": "Favourite list",
"lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title",
"lists.replies_policy.followed": "Any followed user",

View file

@ -109,8 +109,8 @@
"column_header.show_settings": "設定を表示",
"column_header.unpin": "ピン留めを外す",
"column_subheading.favourite_domains": "お気に入りドメイン",
"column_subheading.favourite_lists": "お気に入りリスト",
"column_subheading.favourite_tags": "お気に入りハッシュタグ",
"column_subheading.lists": "リスト",
"column_subheading.settings": "設定",
"community.column_settings.local_only": "ローカルのみ表示",
"community.column_settings.media_only": "メディアのみ表示",
@ -316,6 +316,7 @@
"lists.delete": "リストを削除",
"lists.edit": "リストを編集",
"lists.edit.submit": "タイトルを変更",
"lists.favourite": "リストをお気に入り",
"lists.new.create": "リストを作成",
"lists.new.title_placeholder": "新規リスト名",
"lists.replies_policy.followed": "フォロー中のユーザー全員",

View file

@ -5,6 +5,8 @@ import {
LIST_CREATE_SUCCESS,
LIST_UPDATE_SUCCESS,
LIST_DELETE_SUCCESS,
LIST_FAVOURITE_SUCCESS,
LIST_UNFAVOURITE_SUCCESS,
} from '../actions/lists';
import { Map as ImmutableMap, fromJS } from 'immutable';
@ -25,6 +27,8 @@ export default function lists(state = initialState, action) {
case LIST_FETCH_SUCCESS:
case LIST_CREATE_SUCCESS:
case LIST_UPDATE_SUCCESS:
case LIST_FAVOURITE_SUCCESS:
case LIST_UNFAVOURITE_SUCCESS:
return normalizeList(state, action.list);
case LISTS_FETCH_SUCCESS:
return normalizeLists(state, action.lists);

View file

@ -121,6 +121,8 @@ html {
.getting-started,
.scrollable {
.column-link,
.list-favourite-button,
.list-edit-button,
.circle-edit-button,
.circle-delete-button {
background: $white;

View file

@ -6828,6 +6828,48 @@ noscript {
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 {
background: $ui-base-color;
flex-direction: column;

View file

@ -486,6 +486,12 @@ body.rtl {
left: 20px;
}
.list-link .list-name,
.list-link .list-favourite-button,
.list-link .list-edit-button {
text-align: right;
}
.circle-dropdown .circle-dropdown__menu {
background-position: left 8px center;
}

View file

@ -9,6 +9,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# replies_policy :integer default("list"), not null
# favourite :boolean default(FALSE), not null
#
class List < ApplicationRecord
@ -40,6 +41,20 @@ class List < ApplicationRecord
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
def clean_feed_manager

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
attributes :meta, :compose, :accounts, :lists,
:media_attachments, :settings
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
@ -87,6 +87,11 @@ class InitialStateSerializer < ActiveModel::Serializer
store
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
{ accept_content_types: MediaAttachment.supported_file_extensions + MediaAttachment.supported_mime_types }
end

View file

@ -105,6 +105,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
[
:favourite_hashtag,
:favourite_domain,
:favourite_list,
:status_expire,
:follow_no_delivery,
:follow_hashtag,

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class REST::ListSerializer < ActiveModel::Serializer
attributes :id, :title, :replies_policy
attributes :id, :title, :replies_policy, :favourite
def id
object.id.to_s

View file

@ -493,6 +493,11 @@ Rails.application.routes.draw do
resources :lists, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
resource :subscribes, only: [:show, :create, :destroy], controller: 'lists/subscribes'
member do
post :favourite
post :unfavourite
end
end
resources :circles, only: [:index, :create, :show, :update, :destroy] do

View 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

View file

@ -641,6 +641,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
t.datetime "created_at", null: false
t.datetime "updated_at", 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"
end