diff --git a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js
index 6874452ae..bc011d309 100644
--- a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js
+++ b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js
@@ -1,3 +1,4 @@
+import { fromJS } from 'immutable';
import React from 'react';
import { defineMessages } from 'react-intl';
@@ -14,9 +15,11 @@ describe('', () => {
{}} // 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('', () => {
{}} // 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(
{}} // 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();
});
});
diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx
index 3cd3f5132..e546f2a45 100644
--- a/app/soapbox/components/scrollable_list.tsx
+++ b/app/soapbox/components/scrollable_list.tsx
@@ -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 {
isLoading?: boolean,
showLoading?: boolean,
hasMore?: boolean,
- prepend?: React.ReactElement,
+ prepend?: React.ReactNode,
alwaysPrepend?: boolean,
emptyMessage?: React.ReactNode,
children: Iterable,
onScrollToTop?: () => void,
onScroll?: () => void,
- placeholderComponent?: React.ComponentType,
+ placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
placeholderCount?: number,
onRefresh?: () => Promise,
className?: string,
@@ -49,6 +57,7 @@ interface IScrollableList extends VirtuosoProps {
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
const ScrollableList = React.forwardRef(({
+ scrollKey,
prepend = null,
alwaysPrepend,
children,
@@ -66,13 +75,19 @@ const ScrollableList = React.forwardRef(({
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(scrollData ? scrollData.index : 0);
+ const topOffset = useRef(scrollData ? scrollData.offset : 0);
+
/** Normalized children */
const elements = Array.from(children || []);
@@ -91,6 +106,29 @@ const ScrollableList = React.forwardRef(({
data.push();
}
+ 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(({
}
};
+ 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(() => {
+ 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 => (
(({
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(({
});
export default ScrollableList;
+export type { IScrollableList };
diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx
index 370deb6de..2b1325a60 100644
--- a/app/soapbox/components/status.tsx
+++ b/app/soapbox/components/status.tsx
@@ -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,
focusable: boolean,
history: History,
- featured?: string,
+ featured?: boolean,
+ withDismiss?: boolean,
}
interface IStatusState {
diff --git a/app/soapbox/components/status_list.js b/app/soapbox/components/status_list.js
deleted file mode 100644
index 5d08ab1a2..000000000
--- a/app/soapbox/components/status_list.js
+++ /dev/null
@@ -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 (
- 0 ? statusIds.get(index - 1) : null}
- onClick={onLoadMore}
- />
- );
- }
-
- renderStatus(statusId) {
- const { timelineId, withGroupAdmin, group } = this.props;
-
- return (
-
- );
- }
-
- renderPendingStatus(statusId) {
- const { timelineId, withGroupAdmin, group } = this.props;
- const idempotencyKey = statusId.replace(/^末pending-/, '');
-
- return (
-
- );
- }
-
- renderFeaturedStatuses() {
- const { featuredStatusIds, timelineId } = this.props;
- if (!featuredStatusIds) return null;
-
- return featuredStatusIds.map(statusId => (
-
- ));
- }
-
- 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 (
-
- );
- }
-
- return [
- ,
-
- {this.renderScrollableContent()}
- ,
- ];
- }
-
-}
diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx
new file mode 100644
index 000000000..a421f62a3
--- /dev/null
+++ b/app/soapbox/components/status_list.tsx
@@ -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 {
+ scrollKey: string,
+ statusIds: ImmutableOrderedSet,
+ lastStatusId?: string,
+ featuredStatusIds?: ImmutableOrderedSet,
+ 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 = ({
+ statusIds,
+ lastStatusId,
+ featuredStatusIds,
+ divideType = 'border',
+ onLoadMore,
+ timelineId,
+ isLoading,
+ isPartial,
+ ...other
+}) => {
+ const node = useRef(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 (
+
+ );
+ };
+
+ const renderStatus = (statusId: string) => {
+ return (
+ // @ts-ignore
+
+ );
+ };
+
+ const renderPendingStatus = (statusId: string) => {
+ const idempotencyKey = statusId.replace(/^末pending-/, '');
+
+ return (
+
+ );
+ };
+
+ const renderFeaturedStatuses = (): React.ReactNode[] => {
+ if (!featuredStatusIds) return [];
+
+ return featuredStatusIds.toArray().map(statusId => (
+ // @ts-ignore
+
+ ));
+ };
+
+ 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 (
+
+ );
+ }
+
+ return (
+
+ {renderScrollableContent()}
+
+ );
+};
+
+export default StatusList;
+export type { IStatusList };
diff --git a/app/soapbox/components/timeline_queue_button_header.js b/app/soapbox/components/timeline_queue_button_header.js
deleted file mode 100644
index 29a8e787a..000000000
--- a/app/soapbox/components/timeline_queue_button_header.js
+++ /dev/null
@@ -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 (
-
- );
- }
-
-}
diff --git a/app/soapbox/components/timeline_queue_button_header.tsx b/app/soapbox/components/timeline_queue_button_header.tsx
new file mode 100644
index 000000000..292368571
--- /dev/null
+++ b/app/soapbox/components/timeline_queue_button_header.tsx
@@ -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 = ({
+ 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(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 (
+
+ );
+};
+
+export default TimelineQueueButtonHeader;
diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx
index ebd4eb1cd..0b1255f32 100644
--- a/app/soapbox/features/bookmarks/index.tsx
+++ b/app/soapbox/features/bookmarks/index.tsx
@@ -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 = () => {
handleLoadMore(dispatch)}
diff --git a/app/soapbox/features/conversations/components/conversation.tsx b/app/soapbox/features/conversations/components/conversation.tsx
index 995af88b3..6f638f2d6 100644
--- a/app/soapbox/features/conversations/components/conversation.tsx
+++ b/app/soapbox/features/conversations/components/conversation.tsx
@@ -48,8 +48,8 @@ const Conversation: React.FC = ({ conversationId, onMoveUp, onMov
}
return (
- = (props) => {
case 'poll':
case 'pleroma:emoji_reaction':
return status && typeof status === 'object' ? (
+ // @ts-ignore
{
- 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);
diff --git a/app/soapbox/features/ui/containers/status_list_container.tsx b/app/soapbox/features/ui/containers/status_list_container.tsx
new file mode 100644
index 000000000..4dafbbf6d
--- /dev/null
+++ b/app/soapbox/features/ui/containers/status_list_container.tsx
@@ -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 {
+ timelineId: string,
+}
+
+const StatusListContainer: React.FC = ({
+ 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 (
+ <>
+
+
+
+ >
+ );
+};
+
+export default StatusListContainer;
diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts
index 7d77b4891..fbdadbe37 100644
--- a/app/soapbox/selectors/index.ts
+++ b/app/soapbox/selectors/index.ts
@@ -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, statuses) => {
return statusIds.filter((id: string) => {
const status = statuses.get(id);
if (!status) return true;