mirror of
https://github.com/mastodon/mastodon.git
synced 2024-08-20 21:08:15 -07:00
Display followed hashtags on the home header
Display the followed hashtags on the home header, allowing for easier access to them. Listed from most recent to oldest, and with 2 dedicated buttons. Co-authored-by: Tiago Peralta <tiagofilipeperalta@tecnico.ulisboa.pt>
This commit is contained in:
parent
1bccba1408
commit
77ec956d92
7 changed files with 584 additions and 8 deletions
|
@ -0,0 +1,258 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ButtonScrollList /> handles a large number of children correctly 1`] = `
|
||||
<div
|
||||
className="button-scroll-list-container"
|
||||
>
|
||||
<button
|
||||
aria-label="Scroll left"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className="button-scroll-list"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Scroll right"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<ButtonScrollList /> handles a single child correctly 1`] = `
|
||||
<div
|
||||
className="button-scroll-list-container"
|
||||
>
|
||||
<button
|
||||
aria-label="Scroll left"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className="button-scroll-list"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Scroll right"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<ButtonScrollList /> renders an empty button scroll list element 1`] = `null`;
|
||||
|
||||
exports[`<ButtonScrollList /> renders the children 1`] = `
|
||||
<div
|
||||
className="button-scroll-list-container"
|
||||
>
|
||||
<button
|
||||
aria-label="Scroll left"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className="button-scroll-list"
|
||||
>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
<div>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Scroll right"
|
||||
className="icon-button column-header__setting-btn"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<div>
|
||||
MockIcon
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,119 @@
|
|||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { render, screen } from 'mastodon/test_helpers';
|
||||
|
||||
import ButtonScrollList from '../button_scroll_list';
|
||||
|
||||
jest.mock('mastodon/components/icon', () => {
|
||||
return {
|
||||
Icon: () => <div>MockIcon</div>,
|
||||
};
|
||||
});
|
||||
|
||||
describe('<ButtonScrollList />', () => {
|
||||
it('renders an empty button scroll list element', () => {
|
||||
const children = [];
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the children', () => {
|
||||
const children = Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} ref={jest.fn()} />
|
||||
));
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('scrolls left', () => {
|
||||
const children = Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} ref={jest.fn()} />
|
||||
));
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const instance = component.getInstance();
|
||||
instance.scrollLeft();
|
||||
});
|
||||
|
||||
it('scrolls right', () => {
|
||||
const children = Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} ref={jest.fn()} />
|
||||
));
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const instance = component.getInstance();
|
||||
instance.scrollRight();
|
||||
});
|
||||
|
||||
it('scrolls left and right correctly', () => {
|
||||
const children = Array.from({ length: 10 }, (_, i) => (
|
||||
<div key={i}>{i}</div>
|
||||
));
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
let instance = component.getInstance();
|
||||
instance.scrollRight();
|
||||
expect(instance.slide).toBe(1);
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
let instance = component.getInstance();
|
||||
instance.scrollLeft();
|
||||
expect(instance.slide).toBe(0);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
it('handles a single child correctly', () => {
|
||||
const children = [<div key={0} ref={jest.fn()} />];
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles a large number of children correctly', () => {
|
||||
const children = Array.from({ length: 50 }, (_, i) => (
|
||||
<div key={i} ref={jest.fn()} />
|
||||
));
|
||||
const component = renderer.create(
|
||||
<ButtonScrollList>{children}</ButtonScrollList>,
|
||||
);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('checks if scroll buttons are accessible', () => {
|
||||
const children = Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} ref={jest.fn()} />
|
||||
));
|
||||
render(<ButtonScrollList>{children}</ButtonScrollList>);
|
||||
|
||||
const leftButton = screen.getByRole('button', { name: /scroll left/i });
|
||||
const rightButton = screen.getByRole('button', { name: /scroll right/i });
|
||||
|
||||
expect(leftButton).toBeTruthy();
|
||||
expect(rightButton).toBeTruthy();
|
||||
|
||||
leftButton.focus();
|
||||
expect(document.activeElement).toBe(leftButton);
|
||||
|
||||
rightButton.focus();
|
||||
expect(document.activeElement).toBe(rightButton);
|
||||
});
|
||||
});
|
87
app/javascript/mastodon/components/button_scroll_list.jsx
Normal file
87
app/javascript/mastodon/components/button_scroll_list.jsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
class ButtonScrollList extends Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.scrollRef = React.createRef();
|
||||
this.slide = 0;
|
||||
this.childrenLength = React.Children.count(props.children);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
setTimeout(() => {
|
||||
if (this.scrollRef && this.scrollRef.current) {
|
||||
const container = this.scrollRef.current;
|
||||
container.scrollTo({ left: 0, behavior: 'auto' });
|
||||
this.slide = 0;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
scrollLeft = () => {
|
||||
if (this.scrollRef && this.scrollRef.current) {
|
||||
this.scrollRef.current.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
this.slide = Math.max(0, this.slide - 1);
|
||||
}
|
||||
};
|
||||
|
||||
scrollRight = () => {
|
||||
if (this.scrollRef && this.scrollRef.current) {
|
||||
const { children } = this.props;
|
||||
const container = this.scrollRef.current;
|
||||
const maxScrollLeft = container.scrollWidth - container.clientWidth;
|
||||
|
||||
if (container.scrollLeft < maxScrollLeft) {
|
||||
container.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
this.slide = Math.min(
|
||||
React.Children.count(children) - 1,
|
||||
this.slide + 1,
|
||||
);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
if (React.Children.count(children) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='button-scroll-list-container'>
|
||||
<button
|
||||
className='icon-button column-header__setting-btn'
|
||||
aria-label='Scroll left'
|
||||
onClick={this.scrollLeft}
|
||||
>
|
||||
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||
</button>
|
||||
<div className='button-scroll-list' ref={this.scrollRef}>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<div key={index}>{child}</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className='icon-button column-header__setting-btn'
|
||||
aria-label='Scroll right'
|
||||
onClick={this.scrollRight}
|
||||
>
|
||||
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ButtonScrollList;
|
|
@ -10,6 +10,7 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import FollowedTagsList from 'mastodon/components/followed_tags_list';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
|
@ -255,7 +256,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
<>
|
||||
{backButton}
|
||||
|
||||
<button onClick={handleTitleClick} className='column-header__title'>
|
||||
<button onClick={handleTitleClick} className='column-header__title' style={{ overflow: 'visible', paddingRight: '15px' }}>
|
||||
{!backButton && (
|
||||
<Icon
|
||||
id={icon}
|
||||
|
@ -268,6 +269,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{ icon === 'home' ? (
|
||||
<FollowedTagsList />
|
||||
) : null }
|
||||
|
||||
{!hasTitle && backButton}
|
||||
|
||||
<div className='column-header__buttons'>
|
||||
|
|
67
app/javascript/mastodon/components/followed_tags_list.jsx
Normal file
67
app/javascript/mastodon/components/followed_tags_list.jsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import {
|
||||
expandFollowedHashtags,
|
||||
fetchFollowedHashtags,
|
||||
} from 'mastodon/actions/tags';
|
||||
import ButtonScrollList from 'mastodon/components/button_scroll_list';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
hashtags: state.getIn(['followed_tags', 'items']),
|
||||
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
|
||||
hasMore: !!state.getIn(['followed_tags', 'next']),
|
||||
});
|
||||
|
||||
class FollowedTagsList extends PureComponent {
|
||||
static propTypes = {
|
||||
hashtags: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
hasMore: PropTypes.bool.isRequired,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
|
||||
handleLoadMore = debounce(
|
||||
() => {
|
||||
this.props.dispatch(expandFollowedHashtags());
|
||||
},
|
||||
300,
|
||||
{ leading: true },
|
||||
);
|
||||
|
||||
render() {
|
||||
const { hashtags } = this.props;
|
||||
|
||||
return (
|
||||
<div className='followed-tags-list'>
|
||||
<ButtonScrollList>
|
||||
{hashtags.map((hashtag) => (
|
||||
<div className='hashtag-wrapper' key={hashtag.get('name')}>
|
||||
<Hashtag
|
||||
name={hashtag.get('name')}
|
||||
showSkeleton={false}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
withGraph={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ButtonScrollList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(FollowedTagsList));
|
|
@ -85,6 +85,7 @@ export interface HashtagProps {
|
|||
description?: React.ReactNode;
|
||||
history?: number[];
|
||||
name: string;
|
||||
showSkeleton?: boolean;
|
||||
people: number;
|
||||
to: string;
|
||||
uses?: number;
|
||||
|
@ -93,6 +94,7 @@ export interface HashtagProps {
|
|||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
name,
|
||||
showSkeleton = true,
|
||||
to,
|
||||
people,
|
||||
uses,
|
||||
|
@ -113,13 +115,15 @@ export const Hashtag: React.FC<HashtagProps> = ({
|
|||
)}
|
||||
</Link>
|
||||
|
||||
{description ? (
|
||||
{showSkeleton ? (
|
||||
description ? (
|
||||
<span>{description}</span>
|
||||
) : typeof people !== 'undefined' ? (
|
||||
<ShortNumber value={people} renderer={accountsCountRenderer} />
|
||||
) : (
|
||||
<Skeleton width={100} />
|
||||
)}
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{typeof uses !== 'undefined' && (
|
||||
|
|
|
@ -3571,6 +3571,42 @@ $ui-header-logo-wordmark-width: 99px;
|
|||
}
|
||||
}
|
||||
|
||||
.button-scroll-list-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button-scroll-list {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button-scroll-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.followed-tags-list {
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.hashtag-wrapper {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.column-back-button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
|
|
Loading…
Reference in a new issue