diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button_scroll_list-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button_scroll_list-test.jsx.snap new file mode 100644 index 0000000000..400b6ca87e --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button_scroll_list-test.jsx.snap @@ -0,0 +1,258 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` handles a large number of children correctly 1`] = ` +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+`; + +exports[` handles a single child correctly 1`] = ` +
+ +
+
+
+
+
+ +
+`; + +exports[` renders an empty button scroll list element 1`] = `null`; + +exports[` renders the children 1`] = ` +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+`; diff --git a/app/javascript/mastodon/components/__tests__/button_scroll_list-test.jsx b/app/javascript/mastodon/components/__tests__/button_scroll_list-test.jsx new file mode 100644 index 0000000000..aaa0884831 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/button_scroll_list-test.jsx @@ -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: () =>
MockIcon
, + }; +}); + +describe('', () => { + it('renders an empty button scroll list element', () => { + const children = []; + const component = renderer.create( + {children}, + ); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders the children', () => { + const children = Array.from({ length: 5 }, (_, i) => ( +
+ )); + const component = renderer.create( + {children}, + ); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('scrolls left', () => { + const children = Array.from({ length: 5 }, (_, i) => ( +
+ )); + const component = renderer.create( + {children}, + ); + const instance = component.getInstance(); + instance.scrollLeft(); + }); + + it('scrolls right', () => { + const children = Array.from({ length: 5 }, (_, i) => ( +
+ )); + const component = renderer.create( + {children}, + ); + const instance = component.getInstance(); + instance.scrollRight(); + }); + + it('scrolls left and right correctly', () => { + const children = Array.from({ length: 10 }, (_, i) => ( +
{i}
+ )); + const component = renderer.create( + {children}, + ); + + 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 = [
]; + const component = renderer.create( + {children}, + ); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('handles a large number of children correctly', () => { + const children = Array.from({ length: 50 }, (_, i) => ( +
+ )); + const component = renderer.create( + {children}, + ); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('checks if scroll buttons are accessible', () => { + const children = Array.from({ length: 5 }, (_, i) => ( +
+ )); + render({children}); + + 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); + }); +}); diff --git a/app/javascript/mastodon/components/button_scroll_list.jsx b/app/javascript/mastodon/components/button_scroll_list.jsx new file mode 100644 index 0000000000..f30a54569c --- /dev/null +++ b/app/javascript/mastodon/components/button_scroll_list.jsx @@ -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 ( +
+ +
+ {React.Children.map(children, (child, index) => ( +
{child}
+ ))} +
+ +
+ ); + } +} + +export default ButtonScrollList; diff --git a/app/javascript/mastodon/components/column_header.jsx b/app/javascript/mastodon/components/column_header.jsx index 42183f336d..9e5fedbc80 100644 --- a/app/javascript/mastodon/components/column_header.jsx +++ b/app/javascript/mastodon/components/column_header.jsx @@ -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} - )} + { icon === 'home' ? ( + + ) : null } + {!hasTitle && backButton}
diff --git a/app/javascript/mastodon/components/followed_tags_list.jsx b/app/javascript/mastodon/components/followed_tags_list.jsx new file mode 100644 index 0000000000..a294e6ae6c --- /dev/null +++ b/app/javascript/mastodon/components/followed_tags_list.jsx @@ -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 ( +
+ + {hashtags.map((hashtag) => ( +
+ +
+ ))} +
+
+ ); + } +} + +export default connect(mapStateToProps)(injectIntl(FollowedTagsList)); diff --git a/app/javascript/mastodon/components/hashtag.tsx b/app/javascript/mastodon/components/hashtag.tsx index 8963e4a40d..585d2b27cf 100644 --- a/app/javascript/mastodon/components/hashtag.tsx +++ b/app/javascript/mastodon/components/hashtag.tsx @@ -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 = ({ name, + showSkeleton = true, to, people, uses, @@ -113,13 +115,15 @@ export const Hashtag: React.FC = ({ )} - {description ? ( - {description} - ) : typeof people !== 'undefined' ? ( - - ) : ( - - )} + {showSkeleton ? ( + description ? ( + {description} + ) : typeof people !== 'undefined' ? ( + + ) : ( + + ) + ) : null}
{typeof uses !== 'undefined' && ( diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 859c6e3267..0bb1baa0bf 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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%;