Merge branch 'scroll-position' into 'develop'

Preserve scroll position in feeds

See merge request soapbox-pub/soapbox-fe!1435
This commit is contained in:
Alex Gleason 2022-06-03 12:42:25 +00:00
commit 666c2dd0ce
13 changed files with 459 additions and 423 deletions

View file

@ -1,3 +1,4 @@
import { fromJS } from 'immutable';
import React from 'react';
import { defineMessages } from 'react-intl';
@ -14,9 +15,11 @@ describe('<TimelineQueueButtonHeader />', () => {
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
count={0}
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 0 } }) },
);
expect(screen.queryAllByRole('link')).toHaveLength(0);
@ -24,20 +27,24 @@ describe('<TimelineQueueButtonHeader />', () => {
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
count={1}
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 1 } }) },
);
expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument();
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
count={9999999}
timelineId='home'
message={messages.queue}
/>,
undefined,
{ timelines: fromJS({ home: { totalQueuedItemsCount: 9999999 } }) },
);
expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument();
expect(screen.getByText(/10.*M/, { hidden: true })).toBeInTheDocument();
});
});

View file

@ -1,5 +1,7 @@
import React from 'react';
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso';
import { debounce } from 'lodash';
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso';
import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { useSettings } from 'soapbox/hooks';
@ -12,6 +14,12 @@ type Context = {
listClassName?: string,
}
/** Scroll position saved in sessionStorage. */
type SavedScrollPosition = {
index: number,
offset: number,
}
// 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
@ -31,13 +39,13 @@ interface IScrollableList extends VirtuosoProps<any, any> {
isLoading?: boolean,
showLoading?: boolean,
hasMore?: boolean,
prepend?: React.ReactElement,
prepend?: React.ReactNode,
alwaysPrepend?: boolean,
emptyMessage?: React.ReactNode,
children: Iterable<React.ReactNode>,
onScrollToTop?: () => void,
onScroll?: () => void,
placeholderComponent?: React.ComponentType,
placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
placeholderCount?: number,
onRefresh?: () => Promise<any>,
className?: string,
@ -49,6 +57,7 @@ interface IScrollableList extends VirtuosoProps<any, any> {
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
scrollKey,
prepend = null,
alwaysPrepend,
children,
@ -66,13 +75,19 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
placeholderComponent: Placeholder,
placeholderCount = 0,
initialTopMostItemIndex = 0,
scrollerRef,
style = {},
useWindowScroll = true,
}, ref) => {
const history = useHistory();
const settings = useSettings();
const autoloadMore = settings.get('autoloadMore');
// Preserve scroll position
const scrollDataKey = `soapbox:scrollData:${scrollKey}`;
const scrollData: SavedScrollPosition | null = useMemo(() => JSON.parse(sessionStorage.getItem(scrollDataKey)!), [scrollDataKey]);
const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);
/** Normalized children */
const elements = Array.from(children || []);
@ -91,6 +106,29 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
data.push(<Spinner />);
}
const handleScroll = useCallback(debounce(() => {
// HACK: Virtuoso has no better way to get this...
const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`);
if (node) {
topOffset.current = node.getBoundingClientRect().top * -1;
} else {
topOffset.current = 0;
}
}, 150, { trailing: true }), []);
useEffect(() => {
document.addEventListener('scroll', handleScroll);
sessionStorage.removeItem(scrollDataKey);
return () => {
if (scrollKey) {
const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
}
document.removeEventListener('scroll', handleScroll);
};
}, []);
/* Render an empty state instead of the scrollable list */
const renderEmpty = (): JSX.Element => {
return (
@ -131,6 +169,29 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
}
};
const handleRangeChange = (range: ListRange) => {
// HACK: using the first index can be buggy.
// Track the second item instead, unless the endIndex comes before it (eg one 1 item in view).
topIndex.current = Math.min(range.startIndex + 1, range.endIndex);
handleScroll();
};
/** Figure out the initial index to scroll to. */
const initialIndex = useMemo<number | IndexLocationWithAlign>(() => {
if (showLoading) return 0;
if (initialTopMostItemIndex) return initialTopMostItemIndex;
if (scrollData && history.action === 'POP') {
return {
align: 'start',
index: scrollData.index,
offset: scrollData.offset,
};
}
return 0;
}, [showLoading, initialTopMostItemIndex]);
/** Render the actual Virtuoso list */
const renderFeed = (): JSX.Element => (
<Virtuoso
@ -143,21 +204,21 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
endReached={handleEndReached}
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex}
initialTopMostItemIndex={initialIndex}
rangeChanged={handleRangeChange}
style={style}
context={{
listClassName: className,
itemClassName,
}}
components={{
Header: () => prepend,
Header: () => <>{prepend}</>,
ScrollSeekPlaceholder: Placeholder as any,
EmptyPlaceholder: () => renderEmpty(),
List,
Item,
Footer: loadMore,
}}
scrollerRef={scrollerRef}
/>
);
@ -178,3 +239,4 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
});
export default ScrollableList;
export type { IScrollableList };

View file

@ -61,6 +61,8 @@ export const defaultMediaVisibility = (status: StatusEntity, displayMedia: strin
};
interface IStatus extends RouteComponentProps {
id?: string,
contextType?: string,
intl: IntlShape,
status: StatusEntity,
account: AccountEntity,
@ -87,8 +89,8 @@ interface IStatus extends RouteComponentProps {
muted: boolean,
hidden: boolean,
unread: boolean,
onMoveUp: (statusId: string, featured?: string) => void,
onMoveDown: (statusId: string, featured?: string) => void,
onMoveUp: (statusId: string, featured?: boolean) => void,
onMoveDown: (statusId: string, featured?: boolean) => void,
getScrollPosition?: () => ScrollPosition | undefined,
updateScrollBottom?: (bottom: number) => void,
cacheMediaWidth: () => void,
@ -98,7 +100,8 @@ interface IStatus extends RouteComponentProps {
allowedEmoji: ImmutableList<string>,
focusable: boolean,
history: History,
featured?: string,
featured?: boolean,
withDismiss?: boolean,
}
interface IStatusState {

View file

@ -1,240 +0,0 @@
import classNames from 'classnames';
import { debounce } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage, defineMessages } from 'react-intl';
import StatusContainer from 'soapbox/containers/status_container';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status';
import LoadGap from './load_gap';
import ScrollableList from './scrollable_list';
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}}' },
});
export default class StatusList extends ImmutablePureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.orderedSet.isRequired,
lastStatusId: PropTypes.string,
featuredStatusIds: ImmutablePropTypes.orderedSet,
onLoadMore: PropTypes.func,
isLoading: PropTypes.bool,
isPartial: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
timelineId: PropTypes.string,
queuedItemSize: PropTypes.number,
onDequeueTimeline: PropTypes.func,
group: ImmutablePropTypes.map,
withGroupAdmin: PropTypes.bool,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
divideType: PropTypes.oneOf(['space', 'border']),
};
static defaultProps = {
divideType: 'border',
}
componentDidMount() {
this.handleDequeueTimeline();
}
getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
}
getCurrentStatusIndex = (id, featured) => {
if (featured) {
return this.props.featuredStatusIds.keySeq().findIndex(key => key === id);
} else {
return this.props.statusIds.keySeq().findIndex(key => key === id) + this.getFeaturedStatusCount();
}
}
handleMoveUp = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
this._selectChild(elementIndex, true);
}
handleMoveDown = (id, featured) => {
const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
this._selectChild(elementIndex, false);
}
handleLoadOlder = debounce(() => {
const loadMoreID = this.props.lastStatusId ? this.props.lastStatusId : this.props.statusIds.last();
this.props.onLoadMore(loadMoreID);
}, 300, { leading: true })
_selectChild(index) {
this.node.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`);
if (element) {
element.focus();
}
},
});
}
handleDequeueTimeline = () => {
const { onDequeueTimeline, timelineId } = this.props;
if (!onDequeueTimeline || !timelineId) return;
onDequeueTimeline(timelineId);
}
setRef = c => {
this.node = c;
}
renderLoadGap(index) {
const { statusIds, onLoadMore, isLoading } = this.props;
return (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
);
}
renderStatus(statusId) {
const { timelineId, withGroupAdmin, group } = this.props;
return (
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
/>
);
}
renderPendingStatus(statusId) {
const { timelineId, withGroupAdmin, group } = this.props;
const idempotencyKey = statusId.replace(/^末pending-/, '');
return (
<PendingStatus
key={statusId}
idempotencyKey={idempotencyKey}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
group={group}
withGroupAdmin={withGroupAdmin}
/>
);
}
renderFeaturedStatuses() {
const { featuredStatusIds, timelineId } = this.props;
if (!featuredStatusIds) return null;
return featuredStatusIds.map(statusId => (
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
/>
));
}
renderStatuses() {
const { statusIds, isLoading } = this.props;
if (isLoading || statusIds.size > 0) {
return statusIds.map((statusId, index) => {
if (statusId === null) {
return this.renderLoadGap(index);
} else if (statusId.startsWith('末pending-')) {
return this.renderPendingStatus(statusId);
} else {
return this.renderStatus(statusId);
}
});
} else {
return null;
}
}
renderScrollableContent() {
const featuredStatuses = this.renderFeaturedStatuses();
const statuses = this.renderStatuses();
if (featuredStatuses && statuses) {
return featuredStatuses.concat(statuses);
} else {
return statuses;
}
}
render() {
const { statusIds, divideType, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props;
if (isPartial) {
return (
<div className='regeneration-indicator'>
<div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
}
return [
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={this.handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>,
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0}
onLoadMore={onLoadMore && this.handleLoadOlder}
placeholderComponent={PlaceholderStatus}
placeholderCount={20}
ref={this.setRef}
className={classNames('divide-y divide-solid divide-gray-200 dark:divide-slate-700', {
'divide-none': divideType !== 'border',
})}
itemClassName={classNames({
'pb-3': divideType !== 'border',
})}
{...other}
>
{this.renderScrollableContent()}
</ScrollableList>,
];
}
}

View file

@ -0,0 +1,209 @@
import classNames from 'classnames';
import { debounce } from 'lodash';
import React, { useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import LoadGap from 'soapbox/components/load_gap';
import ScrollableList from 'soapbox/components/scrollable_list';
import StatusContainer from 'soapbox/containers/status_container';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { IScrollableList } from 'soapbox/components/scrollable_list';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
scrollKey: string,
statusIds: ImmutableOrderedSet<string>,
lastStatusId?: string,
featuredStatusIds?: ImmutableOrderedSet<string>,
onLoadMore?: (lastStatusId: string) => void,
isLoading: boolean,
isPartial?: boolean,
hasMore: boolean,
prepend?: React.ReactNode,
emptyMessage: React.ReactNode,
alwaysPrepend?: boolean,
timelineId?: string,
queuedItemSize?: number,
onScrollToTop?: () => void,
onScroll?: () => void,
divideType: 'space' | 'border',
}
const StatusList: React.FC<IStatusList> = ({
statusIds,
lastStatusId,
featuredStatusIds,
divideType = 'border',
onLoadMore,
timelineId,
isLoading,
isPartial,
...other
}) => {
const node = useRef<VirtuosoHandle>(null);
const getFeaturedStatusCount = () => {
return featuredStatusIds?.size || 0;
};
const getCurrentStatusIndex = (id: string, featured: boolean): number => {
if (featured) {
return featuredStatusIds?.keySeq().findIndex(key => key === id) || 0;
} else {
return statusIds.keySeq().findIndex(key => key === id) + getFeaturedStatusCount();
}
};
const handleMoveUp = (id: string, featured: boolean = false) => {
const elementIndex = getCurrentStatusIndex(id, featured) - 1;
selectChild(elementIndex);
};
const handleMoveDown = (id: string, featured: boolean = false) => {
const elementIndex = getCurrentStatusIndex(id, featured) + 1;
selectChild(elementIndex);
};
const handleLoadOlder = useCallback(debounce(() => {
const loadMoreID = lastStatusId || statusIds.last();
if (onLoadMore && loadMoreID) {
onLoadMore(loadMoreID);
}
}, 300, { leading: true }), []);
const selectChild = (index: number) => {
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element: HTMLElement | null = document.querySelector(`#status-list [data-index="${index}"] .focusable`);
element?.focus();
},
});
};
const renderLoadGap = (index: number) => {
const ids = statusIds.toList();
const nextId = ids.get(index + 1);
const prevId = ids.get(index - 1);
if (index < 1 || !nextId || !prevId || !onLoadMore) return null;
return (
<LoadGap
key={'gap:' + nextId}
disabled={isLoading}
maxId={prevId!}
onClick={onLoadMore}
/>
);
};
const renderStatus = (statusId: string) => {
return (
// @ts-ignore
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
/>
);
};
const renderPendingStatus = (statusId: string) => {
const idempotencyKey = statusId.replace(/^末pending-/, '');
return (
<PendingStatus
key={statusId}
idempotencyKey={idempotencyKey}
/>
);
};
const renderFeaturedStatuses = (): React.ReactNode[] => {
if (!featuredStatusIds) return [];
return featuredStatusIds.toArray().map(statusId => (
// @ts-ignore
<StatusContainer
key={`f-${statusId}`}
id={statusId}
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
contextType={timelineId}
/>
));
};
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) {
return statusIds.toArray().map((statusId, index) => {
if (statusId === null) {
return renderLoadGap(index);
} else if (statusId.startsWith('末pending-')) {
return renderPendingStatus(statusId);
} else {
return renderStatus(statusId);
}
});
} else {
return [];
}
};
const renderScrollableContent = () => {
const featuredStatuses = renderFeaturedStatuses();
const statuses = renderStatuses();
if (featuredStatuses && statuses) {
return featuredStatuses.concat(statuses);
} else {
return statuses;
}
};
if (isPartial) {
return (
<div className='regeneration-indicator'>
<div>
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
}
return (
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0}
onLoadMore={handleLoadOlder}
placeholderComponent={PlaceholderStatus}
placeholderCount={20}
ref={node}
className={classNames('divide-y divide-solid divide-gray-200 dark:divide-slate-700', {
'divide-none': divideType !== 'border',
})}
itemClassName={classNames({
'pb-3': divideType !== 'border',
})}
{...other}
>
{renderScrollableContent()}
</ScrollableList>
);
};
export default StatusList;
export type { IStatusList };

View file

@ -1,119 +0,0 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { getSettings } from 'soapbox/actions/settings';
import Icon from 'soapbox/components/icon';
import { Text } from 'soapbox/components/ui';
const mapStateToProps = state => {
const settings = getSettings(state);
return {
autoload: settings.get('autoloadTimelines'),
};
};
export default @connect(mapStateToProps)
@injectIntl
class TimelineQueueButtonHeader extends React.PureComponent {
static propTypes = {
onClick: PropTypes.func.isRequired,
count: PropTypes.number,
message: PropTypes.object.isRequired,
threshold: PropTypes.number,
intl: PropTypes.object.isRequired,
autoload: PropTypes.bool,
autoloadThreshold: PropTypes.number,
};
static defaultProps = {
count: 0,
threshold: 400,
autoload: true,
autoloadThreshold: 50,
};
state = {
scrolled: false,
}
componentDidMount() {
this.attachScrollListener();
}
componentWillUnmount() {
this.detachScrollListener();
}
componentDidUpdate(prevProps, prevState) {
const { scrollTop } = (document.scrollingElement || document.documentElement);
const { count, onClick, autoload, autoloadThreshold } = this.props;
if (autoload && scrollTop <= autoloadThreshold && count !== prevProps.count) {
onClick();
}
}
attachScrollListener() {
window.addEventListener('scroll', this.handleScroll);
}
detachScrollListener() {
window.removeEventListener('scroll', this.handleScroll);
}
handleScroll = throttle(() => {
const { scrollTop } = (document.scrollingElement || document.documentElement);
const { threshold, onClick, autoload, autoloadThreshold } = this.props;
if (autoload && scrollTop <= autoloadThreshold) {
onClick();
}
if (scrollTop > threshold) {
this.setState({ scrolled: true });
} else {
this.setState({ scrolled: false });
}
}, 150, { trailing: true });
scrollUp = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
handleClick = e => {
setTimeout(this.scrollUp, 10);
this.props.onClick(e);
}
render() {
const { count, message, intl } = this.props;
const { scrolled } = this.state;
const visible = count > 0 && scrolled;
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
return (
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={this.handleClick}>
<Icon src={require('@tabler/icons/icons/arrow-bar-to-up.svg')} />
{(count > 0) && (
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
)}
</a>
</div>
);
}
}

View file

@ -0,0 +1,85 @@
import classNames from 'classnames';
import { throttle } from 'lodash';
import React, { useState, useEffect, useCallback } from 'react';
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';
interface ITimelineQueueButtonHeader {
onClick: () => void,
timelineId: string,
message: MessageDescriptor,
threshold?: number,
autoloadThreshold?: number,
}
const TimelineQueueButtonHeader: React.FC<ITimelineQueueButtonHeader> = ({
onClick,
timelineId,
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;
const handleScroll = useCallback(throttle(() => {
const { scrollTop } = (document.scrollingElement || document.documentElement);
if (autoload && scrollTop <= autoloadThreshold) {
onClick();
}
if (scrollTop > threshold) {
setScrolled(true);
} else {
setScrolled(false);
}
}, 150, { trailing: true }), []);
const scrollUp = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleClick: React.MouseEventHandler = () => {
setTimeout(scrollUp, 10);
onClick();
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
const visible = count > 0 && scrolled;
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
return (
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={handleClick}>
<Icon src={require('@tabler/icons/icons/arrow-bar-to-up.svg')} />
{(count > 0) && (
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count: shortNumberFormat(count) })}
</Text>
)}
</a>
</div>
);
};
export default TimelineQueueButtonHeader;

View file

@ -1,13 +1,12 @@
import { debounce } from 'lodash';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
import StatusList from 'soapbox/components/status_list';
import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
@ -18,7 +17,7 @@ const handleLoadMore = debounce((dispatch) => {
}, 300, { leading: true });
const Bookmarks: React.FC = () => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const intl = useIntl();
const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items']));
@ -42,7 +41,7 @@ const Bookmarks: React.FC = () => {
</div>
<StatusList
statusIds={statusIds}
scrollKey={'bookmarked_statuses'}
scrollKey='bookmarked_statuses'
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={() => handleLoadMore(dispatch)}

View file

@ -48,8 +48,8 @@ const Conversation: React.FC<IConversation> = ({ conversationId, onMoveUp, onMov
}
return (
<StatusContainer
// @ts-ignore
<StatusContainer
id={lastStatusId}
unread={unread}
otherAccounts={accounts}

View file

@ -260,8 +260,8 @@ const Notification: React.FC<INotificaton> = (props) => {
case 'poll':
case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? (
// @ts-ignore
<StatusContainer
// @ts-ignore
id={status.id}
withDismiss
hidden={hidden}

View file

@ -1,41 +0,0 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { debounce } from 'lodash';
import { connect } from 'react-redux';
import { dequeueTimeline } from 'soapbox/actions/timelines';
import { scrollTopTimeline } from 'soapbox/actions/timelines';
import StatusList from 'soapbox/components/status_list';
import { makeGetStatusIds } from 'soapbox/selectors';
const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();
const mapStateToProps = (state, { timelineId }) => {
const lastStatusId = state.getIn(['timelines', timelineId, 'items'], ImmutableOrderedSet()).last();
return {
statusIds: getStatusIds(state, { type: timelineId }),
lastStatusId: lastStatusId,
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
totalQueuedItemsCount: state.getIn(['timelines', timelineId, 'totalQueuedItemsCount']),
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, ownProps) => ({
onDequeueTimeline(timelineId) {
dispatch(dequeueTimeline(timelineId, ownProps.onLoadMore));
},
onScrollToTop: debounce(() => {
dispatch(scrollTopTimeline(ownProps.timelineId, true));
}, 100),
onScroll: debounce(() => {
dispatch(scrollTopTimeline(ownProps.timelineId, false));
}, 100),
});
export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);

View file

@ -0,0 +1,71 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { debounce } from 'lodash';
import React, { useCallback } from 'react';
import { defineMessages } from 'react-intl';
import { dequeueTimeline } from 'soapbox/actions/timelines';
import { scrollTopTimeline } from 'soapbox/actions/timelines';
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';
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'> {
timelineId: string,
}
const StatusListContainer: React.FC<IStatusListContainer> = ({
timelineId,
onLoadMore,
...rest
}) => {
const dispatch = useAppDispatch();
const getStatusIds = useCallback(makeGetStatusIds, [])();
const lastStatusId = useAppSelector(state => state.timelines.getIn([timelineId, 'items'], ImmutableOrderedSet()).last() as string | undefined);
const statusIds = useAppSelector(state => getStatusIds(state, { type: timelineId }));
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 handleDequeueTimeline = () => {
dispatch(dequeueTimeline(timelineId, onLoadMore));
};
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, true));
}, 100), []);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, false));
}, 100), []);
return (
<>
<TimelineQueueButtonHeader
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
timelineId={timelineId}
message={messages.queue}
/>
<StatusList
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
lastStatusId={lastStatusId}
statusIds={statusIds}
isLoading={isLoading}
isPartial={isPartial}
hasMore={hasMore}
onLoadMore={onLoadMore}
{...rest}
/>
</>
);
};
export default StatusListContainer;

View file

@ -365,7 +365,7 @@ export const makeGetStatusIds = () => createSelector([
(state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()),
(state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()),
(state: RootState) => state.statuses,
], (columnSettings, statusIds: string[], statuses) => {
], (columnSettings, statusIds: ImmutableOrderedSet<string>, statuses) => {
return statusIds.filter((id: string) => {
const status = statuses.get(id);
if (!status) return true;