Merge branch 'timeline-fixes' into 'develop'

TImeline fixes

See merge request soapbox-pub/soapbox-fe!1491
dnd
Alex Gleason 2022-06-03 18:45:30 +00:00
commit 4afc38ba0f
16 zmienionych plików z 133 dodań i 103 usunięć

Wyświetl plik

@ -0,0 +1,43 @@
import React from 'react';
import { defineMessages } from 'react-intl';
import { render, screen } from '../../jest/test-helpers';
import ScrollTopButton from '../scroll-top-button';
const messages = defineMessages({
queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' },
});
describe('<ScrollTopButton />', () => {
it('renders correctly', async() => {
render(
<ScrollTopButton
key='scroll-top-button'
onClick={() => {}}
count={0}
message={messages.queue}
/>,
);
expect(screen.queryAllByRole('link')).toHaveLength(0);
render(
<ScrollTopButton
key='scroll-top-button'
onClick={() => {}}
count={1}
message={messages.queue}
/>,
);
expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument();
render(
<ScrollTopButton
key='scroll-top-button'
onClick={() => {}}
count={9999999}
message={messages.queue}
/>,
);
expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument();
});
});

Wyświetl plik

@ -1,50 +0,0 @@
import { fromJS } from 'immutable';
import React from 'react';
import { defineMessages } from 'react-intl';
import { render, screen } from '../../jest/test-helpers';
import TimelineQueueButtonHeader from '../timeline_queue_button_header';
const messages = defineMessages({
queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' },
});
describe('<TimelineQueueButtonHeader />', () => {
it('renders correctly', async() => {
render(
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 0 } }) },
);
expect(screen.queryAllByRole('link')).toHaveLength(0);
render(
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 1 } }) },
);
expect(screen.getByText(/Click to see\s+1\s+new post/, { hidden: true })).toBeInTheDocument();
render(
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 9999999 } }) },
);
expect(screen.getByText(/10.*M/, { hidden: true })).toBeInTheDocument();
});
});

Wyświetl plik

@ -5,27 +5,31 @@ import { useIntl, MessageDescriptor } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { Text } from 'soapbox/components/ui';
import { useAppSelector, useSettings } from 'soapbox/hooks';
import { shortNumberFormat } from 'soapbox/utils/numbers';
import { useSettings } from 'soapbox/hooks';
interface ITimelineQueueButtonHeader {
interface IScrollTopButton {
/** Callback when clicked, and also when scrolled to the top. */
onClick: () => void,
timelineId: string,
/** Number of unread items. */
count: number,
/** Message to display in the button (should contain a `{count}` value). */
message: MessageDescriptor,
/** Distance from the top of the screen (scrolling down) before the button appears. */
threshold?: number,
/** Distance from the top of the screen (scrolling up) before the action is triggered. */
autoloadThreshold?: number,
}
const TimelineQueueButtonHeader: React.FC<ITimelineQueueButtonHeader> = ({
/** Floating new post counter above timelines, clicked to scroll to top. */
const ScrollTopButton: React.FC<IScrollTopButton> = ({
onClick,
timelineId,
count,
message,
threshold = 400,
autoloadThreshold = 50,
}) => {
const intl = useIntl();
const settings = useSettings();
const count = useAppSelector(state => state.timelines.getIn([timelineId, 'totalQueuedItemsCount']));
const [scrolled, setScrolled] = useState<boolean>(false);
const autoload = settings.get('autoloadTimelines') === true;
@ -42,7 +46,7 @@ const TimelineQueueButtonHeader: React.FC<ITimelineQueueButtonHeader> = ({
} else {
setScrolled(false);
}
}, 150, { trailing: true }), []);
}, 150, { trailing: true }), [autoload, threshold, autoloadThreshold]);
const scrollUp = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@ -74,7 +78,7 @@ const TimelineQueueButtonHeader: React.FC<ITimelineQueueButtonHeader> = ({
{(count > 0) && (
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count: shortNumberFormat(count) })}
{intl.formatMessage(message, { count })}
</Text>
)}
</a>
@ -82,4 +86,4 @@ const TimelineQueueButtonHeader: React.FC<ITimelineQueueButtonHeader> = ({
);
};
export default TimelineQueueButtonHeader;
export default ScrollTopButton;

Wyświetl plik

@ -9,6 +9,7 @@ import { useSettings } from 'soapbox/hooks';
import LoadMore from './load_more';
import { Spinner, Text } from './ui';
/** Custom Viruoso component context. */
type Context = {
itemClassName?: string,
listClassName?: string,
@ -20,6 +21,7 @@ type SavedScrollPosition = {
offset: number,
}
/** Custom Virtuoso Item component representing a single scrollable item. */
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
@ -27,6 +29,7 @@ const Item: Components<Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
@ -34,28 +37,47 @@ const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
});
interface IScrollableList extends VirtuosoProps<any, any> {
/** Unique key to preserve the scroll position when navigating back. */
scrollKey?: string,
/** Pagination callback when the end of the list is reached. */
onLoadMore?: () => void,
/** Whether the data is currently being fetched. */
isLoading?: boolean,
/** Whether to actually display the loading state. */
showLoading?: boolean,
/** Whether we expect an additional page of data. */
hasMore?: boolean,
/** Additional element to display at the top of the list. */
prepend?: React.ReactNode,
/** Whether to display the prepended element. */
alwaysPrepend?: boolean,
/** Message to display when the list is loaded but empty. */
emptyMessage?: React.ReactNode,
/** Scrollable content. */
children: Iterable<React.ReactNode>,
/** Callback when the list is scrolled to the top. */
onScrollToTop?: () => void,
/** Callback when the list is scrolled. */
onScroll?: () => void,
/** Placeholder component to render while loading. */
placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
/** Number of placeholders to render while loading. */
placeholderCount?: number,
/** Pull to refresh callback. */
onRefresh?: () => Promise<any>,
/** Extra class names on the Virtuoso element. */
className?: string,
/** Class names on each item container. */
itemClassName?: string,
/** `id` attribute on the Virtuoso element. */
id?: string,
/** CSS styles on the Virtuoso element. */
style?: React.CSSProperties,
/** Whether to use the window to scroll the content instead of Virtuoso's container. */
useWindowScroll?: boolean
}
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
/** Legacy ScrollableList with Virtuoso for backwards-compatibility. */
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
scrollKey,
prepend = null,
@ -88,7 +110,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);
/** Normalized children */
/** Normalized children. */
const elements = Array.from(children || []);
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
@ -129,7 +151,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
};
}, []);
/* Render an empty state instead of the scrollable list */
/* Render an empty state instead of the scrollable list. */
const renderEmpty = (): JSX.Element => {
return (
<div className='mt-2'>
@ -146,7 +168,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
);
};
/** Render a single item */
/** Render a single item. */
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
if (showPlaceholder) {
return <Placeholder />;
@ -192,7 +214,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
return 0;
}, [showLoading, initialTopMostItemIndex]);
/** Render the actual Virtuoso list */
/** Render the actual Virtuoso list. */
const renderFeed = (): JSX.Element => (
<Virtuoso
ref={ref}
@ -222,7 +244,7 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
/>
);
/** Conditionally render inner elements */
/** Conditionally render inner elements. */
const renderBody = (): JSX.Element => {
if (isEmpty) {
return renderEmpty();

Wyświetl plik

@ -14,24 +14,31 @@ import type { VirtuosoHandle } from 'react-virtuoso';
import type { IScrollableList } from 'soapbox/components/scrollable_list';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** Unique key to preserve the scroll position when navigating back. */
scrollKey: string,
/** List of status IDs to display. */
statusIds: ImmutableOrderedSet<string>,
/** Last _unfiltered_ status ID (maxId) for pagination. */
lastStatusId?: string,
/** Pinned statuses to show at the top of the feed. */
featuredStatusIds?: ImmutableOrderedSet<string>,
/** Pagination callback when the end of the list is reached. */
onLoadMore?: (lastStatusId: string) => void,
/** Whether the data is currently being fetched. */
isLoading: boolean,
/** Whether the server did not return a complete page. */
isPartial?: boolean,
/** Whether we expect an additional page of data. */
hasMore: boolean,
prepend?: React.ReactNode,
/** Message to display when the list is loaded but empty. */
emptyMessage: React.ReactNode,
alwaysPrepend?: boolean,
/** ID of the timeline in Redux. */
timelineId?: string,
queuedItemSize?: number,
onScrollToTop?: () => void,
onScroll?: () => void,
/** Whether to display a gap or border between statuses in the list. */
divideType: 'space' | 'border',
}
/** Feed of statuses, built atop ScrollableList. */
const StatusList: React.FC<IStatusList> = ({
statusIds,
lastStatusId,
@ -68,11 +75,11 @@ const StatusList: React.FC<IStatusList> = ({
};
const handleLoadOlder = useCallback(debounce(() => {
const loadMoreID = lastStatusId || statusIds.last();
if (onLoadMore && loadMoreID) {
onLoadMore(loadMoreID);
const maxId = lastStatusId || statusIds.last();
if (onLoadMore && maxId) {
onLoadMore(maxId);
}
}, 300, { leading: true }), []);
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]);
const selectChild = (index: number) => {
node.current?.scrollIntoView({

Wyświetl plik

@ -9,7 +9,7 @@ import { expandCommunityTimeline } from 'soapbox/actions/timelines';
import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
@ -81,7 +81,7 @@ class CommunityTimeline extends React.PureComponent {
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} settings={ColumnSettings} />
<StatusListContainer
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}

Wyświetl plik

@ -9,7 +9,7 @@ import ColumnHeader from 'soapbox/components/column_header';
import { Column } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
@ -52,7 +52,7 @@ const DirectTimeline = () => {
onSelected={handleSuggestion}
/>
<StatusListContainer
<Timeline
scrollKey='direct_timeline'
timelineId='direct'
onLoadMore={handleLoadMore}

Wyświetl plik

@ -12,7 +12,7 @@ import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines';
import Avatar from '../../../components/avatar';
import MissingIndicator from '../../../components/missing_indicator';
import StatusListContainer from '../../ui/containers/status_list_container';
import Timeline from '../../ui/components/timeline';
const mapStateToProps = (state, props) => {
const me = state.get('me');
@ -90,7 +90,7 @@ class GroupTimeline extends React.PureComponent {
)}
<div className='group__feed'>
<StatusListContainer
<Timeline
alwaysPrepend
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}`}

Wyświetl plik

@ -8,7 +8,7 @@ import { connectHashtagStream } from '../../actions/streaming';
import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines';
import ColumnHeader from '../../components/column_header';
import { Column } from '../../components/ui';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
const mapStateToProps = (state, props) => ({
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
@ -114,7 +114,7 @@ class HashtagTimeline extends React.PureComponent {
return (
<Column label={`#${id}`} transparent>
<ColumnHeader active={hasUnread} title={this.title()} />
<StatusListContainer
<Timeline
scrollKey='hashtag_timeline'
timelineId={`hashtag:${id}`}
onLoadMore={this.handleLoadMore}

Wyświetl plik

@ -10,7 +10,7 @@ import { getFeatures } from 'soapbox/utils/features';
import { expandHomeTimeline } from '../../actions/timelines';
import { Column } from '../../components/ui';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
function FollowRecommendationsContainer() {
return import(/* webpackChunkName: "features/follow_recommendations" */'soapbox/features/follow_recommendations/components/follow_recommendations_container');
@ -114,7 +114,7 @@ class HomeTimeline extends React.PureComponent {
{Component => <Component onDone={this.handleDone} />}
</BundleContainer>
) : (
<StatusListContainer
<Timeline
scrollKey='home_timeline'
onLoadMore={this.handleLoadMore}
onRefresh={this.handleRefresh}

Wyświetl plik

@ -12,7 +12,7 @@ import { Button, Spinner } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
import { useAppSelector } from 'soapbox/hooks';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
// const messages = defineMessages({
// deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' },
@ -110,7 +110,7 @@ const ListTimeline: React.FC = () => {
</div>
</HomeColumnHeader> */}
<StatusListContainer
<Timeline
scrollKey='list_timeline'
timelineId={`list:${id}`}
onLoadMore={handleLoadMore}

Wyświetl plik

@ -14,8 +14,8 @@ import {
dequeueNotifications,
} from 'soapbox/actions/notifications';
import { getSettings } from 'soapbox/actions/settings';
import ScrollTopButton from 'soapbox/components/scroll-top-button';
import ScrollableList from 'soapbox/components/scrollable_list';
import TimelineQueueButtonHeader from 'soapbox/components/timeline_queue_button_header';
import { Column } from 'soapbox/components/ui';
import PlaceholderNotification from 'soapbox/features/placeholder/components/placeholder_notification';
@ -195,7 +195,7 @@ class Notifications extends React.PureComponent {
return (
<Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)} withHeader={false}>
{filterBarContainer}
<TimelineQueueButtonHeader
<ScrollTopButton
onClick={this.handleDequeueNotifications}
count={totalQueuedNotificationsCount}
message={messages.queue}

Wyświetl plik

@ -12,7 +12,7 @@ import { Column } from 'soapbox/components/ui';
import Accordion from 'soapbox/features/ui/components/accordion';
import PinnedHostsPicker from '../remote_timeline/components/pinned_hosts_picker';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
import ColumnSettings from './containers/column_settings_container';
@ -130,7 +130,7 @@ class CommunityTimeline extends React.PureComponent {
/>
</Accordion>
</div>}
<StatusListContainer
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}

Wyświetl plik

@ -9,7 +9,7 @@ import { HStack, Text } from 'soapbox/components/ui';
import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useSettings } from 'soapbox/hooks';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
import PinnedHostsPicker from './components/pinned_hosts_picker';
@ -77,7 +77,7 @@ const RemoteTimeline: React.FC<IRemoteTimeline> = ({ params }) => {
/>
</Text>
</HStack>}
<StatusListContainer
<Timeline
scrollKey={`${timelineId}_${instance}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}:${instance}`}
onLoadMore={handleLoadMore}

Wyświetl plik

@ -7,7 +7,7 @@ import { expandTimelineSuccess } from 'soapbox/actions/timelines';
import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from '../../components/ui';
import StatusListContainer from '../ui/containers/status_list_container';
import Timeline from '../ui/components/timeline';
const messages = defineMessages({
title: { id: 'column.test', defaultMessage: 'Test timeline' },
@ -40,7 +40,7 @@ const TestTimeline: React.FC = () => {
return (
<Column label={intl.formatMessage(messages.title)} transparent>
<SubNavigation message={intl.formatMessage(messages.title)} />
<StatusListContainer
<Timeline
scrollKey={`${timelineId}_timeline`}
timelineId={`${timelineId}${onlyMedia ? ':media' : ''}`}
emptyMessage={<FormattedMessage id='empty_column.test' defaultMessage='The test timeline is empty.' />}

Wyświetl plik

@ -5,8 +5,8 @@ import { defineMessages } from 'react-intl';
import { dequeueTimeline } from 'soapbox/actions/timelines';
import { scrollTopTimeline } from 'soapbox/actions/timelines';
import ScrollTopButton from 'soapbox/components/scroll-top-button';
import StatusList, { IStatusList } from 'soapbox/components/status_list';
import TimelineQueueButtonHeader from 'soapbox/components/timeline_queue_button_header';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetStatusIds } from 'soapbox/selectors';
@ -14,11 +14,13 @@ const messages = defineMessages({
queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' },
});
interface IStatusListContainer extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
/** ID of the timeline in Redux. */
timelineId: string,
}
const StatusListContainer: React.FC<IStatusListContainer> = ({
/** Scrollable list of statuses from a timeline in the Redux store. */
const Timeline: React.FC<ITimeline> = ({
timelineId,
onLoadMore,
...rest
@ -31,6 +33,7 @@ const StatusListContainer: React.FC<IStatusListContainer> = ({
const isLoading = useAppSelector(state => state.timelines.getIn([timelineId, 'isLoading'], true) === true);
const isPartial = useAppSelector(state => state.timelines.getIn([timelineId, 'isPartial'], false) === true);
const hasMore = useAppSelector(state => state.timelines.getIn([timelineId, 'hasMore']) === true);
const totalQueuedItemsCount = useAppSelector(state => state.timelines.getIn([timelineId, 'totalQueuedItemsCount']));
const handleDequeueTimeline = () => {
dispatch(dequeueTimeline(timelineId, onLoadMore));
@ -38,22 +41,23 @@ const StatusListContainer: React.FC<IStatusListContainer> = ({
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, true));
}, 100), []);
}, 100), [timelineId]);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, false));
}, 100), []);
}, 100), [timelineId]);
return (
<>
<TimelineQueueButtonHeader
<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
timelineId={timelineId}
count={totalQueuedItemsCount}
message={messages.queue}
/>
<StatusList
timelineId={timelineId}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
lastStatusId={lastStatusId}
@ -68,4 +72,4 @@ const StatusListContainer: React.FC<IStatusListContainer> = ({
);
};
export default StatusListContainer;
export default Timeline;