Merge branch 'scroll-position' into 'develop'
Preserve scroll position in feeds See merge request soapbox-pub/soapbox-fe!1435
This commit is contained in:
commit
666c2dd0ce
13 changed files with 459 additions and 423 deletions
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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…' />
|
||||
<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>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
209
app/soapbox/components/status_list.tsx
Normal file
209
app/soapbox/components/status_list.tsx
Normal 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…' />
|
||||
<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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
85
app/soapbox/components/timeline_queue_button_header.tsx
Normal file
85
app/soapbox/components/timeline_queue_button_header.tsx
Normal 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;
|
|
@ -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)}
|
||||
|
|
|
@ -48,8 +48,8 @@ const Conversation: React.FC<IConversation> = ({ conversationId, onMoveUp, onMov
|
|||
}
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
// @ts-ignore
|
||||
<StatusContainer
|
||||
id={lastStatusId}
|
||||
unread={unread}
|
||||
otherAccounts={accounts}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
71
app/soapbox/features/ui/containers/status_list_container.tsx
Normal file
71
app/soapbox/features/ui/containers/status_list_container.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue