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>
pull/30448/head
Alexandre Umbelino 2024-05-27 13:08:15 +01:00
parent ed99923138
commit 2d5c56a683
7 changed files with 584 additions and 9 deletions

View File

@ -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>
`;

View File

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

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

View File

@ -12,12 +12,12 @@ 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 { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { useAppHistory } from './router';
const messages = defineMessages({
@ -194,13 +194,17 @@ class ColumnHeader extends PureComponent {
<>
{backButton}
<button onClick={this.handleTitleClick} className='column-header__title'>
<button onClick={this.handleTitleClick} className='column-header__title' style={{ overflow: 'visible', paddingRight: '15px' }}>
{!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)}
{ icon === 'home' ? (
<FollowedTagsList />
) : null }
{!hasTitle && backButton}
<div className='column-header__buttons'>

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

View File

@ -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 ? (
<span>{description}</span>
) : typeof people !== 'undefined' ? (
<ShortNumber value={people} renderer={accountsCountRenderer} />
) : (
<Skeleton width={100} />
)}
{showSkeleton ? (
description ? (
<span>{description}</span>
) : typeof people !== 'undefined' ? (
<ShortNumber value={people} renderer={accountsCountRenderer} />
) : (
<Skeleton width={100} />
)
) : null}
</div>
{typeof uses !== 'undefined' && (

View File

@ -3541,6 +3541,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%;