1
0
Fork 0
mirror of https://github.com/mastodon/mastodon.git synced 2024-08-20 21:08:15 -07:00

Compare commits

...

3 commits

Author SHA1 Message Date
Tiago Peralta
3f8f95ca45
Merge 2166f44079 into 549ab089ee 2024-07-31 11:06:48 +00:00
Alexandre Umbelino
2166f44079 Error cleaning 2024-06-28 20:16:32 +01:00
Alexandre Umbelino
77ec956d92 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>
2024-06-28 15:59:31 +01:00
7 changed files with 585 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,86 @@
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,
);
}
}
};
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 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,11 @@ 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 +273,8 @@ export const ColumnHeader: React.FC<Props> = ({
</>
)}
{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 ? (
{showSkeleton ? (
description ? (
<span>{description}</span>
) : typeof people !== 'undefined' ? (
<ShortNumber value={people} renderer={accountsCountRenderer} />
) : (
<Skeleton width={100} />
)}
)
) : null}
</div>
{typeof uses !== 'undefined' && (

View file

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