1
0
Fork 0
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:
Alexandre Umbelino 2024-05-27 13:08:15 +01:00
parent 1bccba1408
commit 77ec956d92
7 changed files with 584 additions and 8 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

@ -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 ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.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 type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
@ -255,7 +256,7 @@ export const ColumnHeader: React.FC<Props> = ({
<> <>
{backButton} {backButton}
<button onClick={handleTitleClick} className='column-header__title'> <button onClick={handleTitleClick} className='column-header__title' style={{ overflow: 'visible', paddingRight: '15px' }}>
{!backButton && ( {!backButton && (
<Icon <Icon
id={icon} id={icon}
@ -268,6 +269,10 @@ export const ColumnHeader: React.FC<Props> = ({
</> </>
)} )}
{ icon === 'home' ? (
<FollowedTagsList />
) : null }
{!hasTitle && backButton} {!hasTitle && backButton}
<div className='column-header__buttons'> <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; description?: React.ReactNode;
history?: number[]; history?: number[];
name: string; name: string;
showSkeleton?: boolean;
people: number; people: number;
to: string; to: string;
uses?: number; uses?: number;
@ -93,6 +94,7 @@ export interface HashtagProps {
export const Hashtag: React.FC<HashtagProps> = ({ export const Hashtag: React.FC<HashtagProps> = ({
name, name,
showSkeleton = true,
to, to,
people, people,
uses, uses,
@ -113,13 +115,15 @@ export const Hashtag: React.FC<HashtagProps> = ({
)} )}
</Link> </Link>
{description ? ( {showSkeleton ? (
<span>{description}</span> description ? (
) : typeof people !== 'undefined' ? ( <span>{description}</span>
<ShortNumber value={people} renderer={accountsCountRenderer} /> ) : typeof people !== 'undefined' ? (
) : ( <ShortNumber value={people} renderer={accountsCountRenderer} />
<Skeleton width={100} /> ) : (
)} <Skeleton width={100} />
)
) : null}
</div> </div>
{typeof uses !== 'undefined' && ( {typeof uses !== 'undefined' && (

View file

@ -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 { .column-back-button {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;