kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'scroll-position' into 'develop'
Preserve scroll position in feeds See merge request soapbox-pub/soapbox-fe!1435environments/review-develop-3zknud/deployments/175
commit
666c2dd0ce
|
@ -1,3 +1,4 @@
|
||||||
|
import { fromJS } from 'immutable';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
@ -14,9 +15,11 @@ describe('<TimelineQueueButtonHeader />', () => {
|
||||||
<TimelineQueueButtonHeader
|
<TimelineQueueButtonHeader
|
||||||
key='timeline-queue-button-header'
|
key='timeline-queue-button-header'
|
||||||
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
||||||
count={0}
|
timelineId='home'
|
||||||
message={messages.queue}
|
message={messages.queue}
|
||||||
/>,
|
/>,
|
||||||
|
undefined,
|
||||||
|
{ timelines: fromJS({ home: { totalQueuedItemsCount: 0 } }) },
|
||||||
);
|
);
|
||||||
expect(screen.queryAllByRole('link')).toHaveLength(0);
|
expect(screen.queryAllByRole('link')).toHaveLength(0);
|
||||||
|
|
||||||
|
@ -24,20 +27,24 @@ describe('<TimelineQueueButtonHeader />', () => {
|
||||||
<TimelineQueueButtonHeader
|
<TimelineQueueButtonHeader
|
||||||
key='timeline-queue-button-header'
|
key='timeline-queue-button-header'
|
||||||
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
||||||
count={1}
|
timelineId='home'
|
||||||
message={messages.queue}
|
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(
|
render(
|
||||||
<TimelineQueueButtonHeader
|
<TimelineQueueButtonHeader
|
||||||
key='timeline-queue-button-header'
|
key='timeline-queue-button-header'
|
||||||
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
onClick={() => {}} // eslint-disable-line react/jsx-no-bind
|
||||||
count={9999999}
|
timelineId='home'
|
||||||
message={messages.queue}
|
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 { debounce } from 'lodash';
|
||||||
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso';
|
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 PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
import { useSettings } from 'soapbox/hooks';
|
import { useSettings } from 'soapbox/hooks';
|
||||||
|
@ -12,6 +14,12 @@ type Context = {
|
||||||
listClassName?: string,
|
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!
|
// NOTE: It's crucial to space lists with **padding** instead of margin!
|
||||||
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
|
// 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
|
// 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,
|
isLoading?: boolean,
|
||||||
showLoading?: boolean,
|
showLoading?: boolean,
|
||||||
hasMore?: boolean,
|
hasMore?: boolean,
|
||||||
prepend?: React.ReactElement,
|
prepend?: React.ReactNode,
|
||||||
alwaysPrepend?: boolean,
|
alwaysPrepend?: boolean,
|
||||||
emptyMessage?: React.ReactNode,
|
emptyMessage?: React.ReactNode,
|
||||||
children: Iterable<React.ReactNode>,
|
children: Iterable<React.ReactNode>,
|
||||||
onScrollToTop?: () => void,
|
onScrollToTop?: () => void,
|
||||||
onScroll?: () => void,
|
onScroll?: () => void,
|
||||||
placeholderComponent?: React.ComponentType,
|
placeholderComponent?: React.ComponentType | React.NamedExoticComponent,
|
||||||
placeholderCount?: number,
|
placeholderCount?: number,
|
||||||
onRefresh?: () => Promise<any>,
|
onRefresh?: () => Promise<any>,
|
||||||
className?: string,
|
className?: string,
|
||||||
|
@ -49,6 +57,7 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
||||||
|
|
||||||
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
|
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
|
||||||
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
|
scrollKey,
|
||||||
prepend = null,
|
prepend = null,
|
||||||
alwaysPrepend,
|
alwaysPrepend,
|
||||||
children,
|
children,
|
||||||
|
@ -66,13 +75,19 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
placeholderComponent: Placeholder,
|
placeholderComponent: Placeholder,
|
||||||
placeholderCount = 0,
|
placeholderCount = 0,
|
||||||
initialTopMostItemIndex = 0,
|
initialTopMostItemIndex = 0,
|
||||||
scrollerRef,
|
|
||||||
style = {},
|
style = {},
|
||||||
useWindowScroll = true,
|
useWindowScroll = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
const history = useHistory();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const autoloadMore = settings.get('autoloadMore');
|
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 */
|
/** Normalized children */
|
||||||
const elements = Array.from(children || []);
|
const elements = Array.from(children || []);
|
||||||
|
|
||||||
|
@ -91,6 +106,29 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
data.push(<Spinner />);
|
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 */
|
/* Render an empty state instead of the scrollable list */
|
||||||
const renderEmpty = (): JSX.Element => {
|
const renderEmpty = (): JSX.Element => {
|
||||||
return (
|
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 */
|
/** Render the actual Virtuoso list */
|
||||||
const renderFeed = (): JSX.Element => (
|
const renderFeed = (): JSX.Element => (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
@ -143,21 +204,21 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
endReached={handleEndReached}
|
endReached={handleEndReached}
|
||||||
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
||||||
itemContent={renderItem}
|
itemContent={renderItem}
|
||||||
initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex}
|
initialTopMostItemIndex={initialIndex}
|
||||||
|
rangeChanged={handleRangeChange}
|
||||||
style={style}
|
style={style}
|
||||||
context={{
|
context={{
|
||||||
listClassName: className,
|
listClassName: className,
|
||||||
itemClassName,
|
itemClassName,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Header: () => prepend,
|
Header: () => <>{prepend}</>,
|
||||||
ScrollSeekPlaceholder: Placeholder as any,
|
ScrollSeekPlaceholder: Placeholder as any,
|
||||||
EmptyPlaceholder: () => renderEmpty(),
|
EmptyPlaceholder: () => renderEmpty(),
|
||||||
List,
|
List,
|
||||||
Item,
|
Item,
|
||||||
Footer: loadMore,
|
Footer: loadMore,
|
||||||
}}
|
}}
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -178,3 +239,4 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ScrollableList;
|
export default ScrollableList;
|
||||||
|
export type { IScrollableList };
|
||||||
|
|
|
@ -61,6 +61,8 @@ export const defaultMediaVisibility = (status: StatusEntity, displayMedia: strin
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IStatus extends RouteComponentProps {
|
interface IStatus extends RouteComponentProps {
|
||||||
|
id?: string,
|
||||||
|
contextType?: string,
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
status: StatusEntity,
|
status: StatusEntity,
|
||||||
account: AccountEntity,
|
account: AccountEntity,
|
||||||
|
@ -87,8 +89,8 @@ interface IStatus extends RouteComponentProps {
|
||||||
muted: boolean,
|
muted: boolean,
|
||||||
hidden: boolean,
|
hidden: boolean,
|
||||||
unread: boolean,
|
unread: boolean,
|
||||||
onMoveUp: (statusId: string, featured?: string) => void,
|
onMoveUp: (statusId: string, featured?: boolean) => void,
|
||||||
onMoveDown: (statusId: string, featured?: string) => void,
|
onMoveDown: (statusId: string, featured?: boolean) => void,
|
||||||
getScrollPosition?: () => ScrollPosition | undefined,
|
getScrollPosition?: () => ScrollPosition | undefined,
|
||||||
updateScrollBottom?: (bottom: number) => void,
|
updateScrollBottom?: (bottom: number) => void,
|
||||||
cacheMediaWidth: () => void,
|
cacheMediaWidth: () => void,
|
||||||
|
@ -98,7 +100,8 @@ interface IStatus extends RouteComponentProps {
|
||||||
allowedEmoji: ImmutableList<string>,
|
allowedEmoji: ImmutableList<string>,
|
||||||
focusable: boolean,
|
focusable: boolean,
|
||||||
history: History,
|
history: History,
|
||||||
featured?: string,
|
featured?: boolean,
|
||||||
|
withDismiss?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IStatusState {
|
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>,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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 { debounce } from 'lodash';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
|
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks';
|
||||||
import StatusList from 'soapbox/components/status_list';
|
import StatusList from 'soapbox/components/status_list';
|
||||||
import SubNavigation from 'soapbox/components/sub_navigation';
|
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||||
import { Column } from 'soapbox/components/ui';
|
import { Column } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
|
@ -18,7 +17,7 @@ const handleLoadMore = debounce((dispatch) => {
|
||||||
}, 300, { leading: true });
|
}, 300, { leading: true });
|
||||||
|
|
||||||
const Bookmarks: React.FC = () => {
|
const Bookmarks: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items']));
|
const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items']));
|
||||||
|
@ -42,7 +41,7 @@ const Bookmarks: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<StatusList
|
<StatusList
|
||||||
statusIds={statusIds}
|
statusIds={statusIds}
|
||||||
scrollKey={'bookmarked_statuses'}
|
scrollKey='bookmarked_statuses'
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onLoadMore={() => handleLoadMore(dispatch)}
|
onLoadMore={() => handleLoadMore(dispatch)}
|
||||||
|
|
|
@ -48,8 +48,8 @@ const Conversation: React.FC<IConversation> = ({ conversationId, onMoveUp, onMov
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusContainer
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
<StatusContainer
|
||||||
id={lastStatusId}
|
id={lastStatusId}
|
||||||
unread={unread}
|
unread={unread}
|
||||||
otherAccounts={accounts}
|
otherAccounts={accounts}
|
||||||
|
|
|
@ -260,8 +260,8 @@ const Notification: React.FC<INotificaton> = (props) => {
|
||||||
case 'poll':
|
case 'poll':
|
||||||
case 'pleroma:emoji_reaction':
|
case 'pleroma:emoji_reaction':
|
||||||
return status && typeof status === 'object' ? (
|
return status && typeof status === 'object' ? (
|
||||||
<StatusContainer
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
<StatusContainer
|
||||||
id={status.id}
|
id={status.id}
|
||||||
withDismiss
|
withDismiss
|
||||||
hidden={hidden}
|
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);
|
|
|
@ -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, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()),
|
||||||
(state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()),
|
(state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()),
|
||||||
(state: RootState) => state.statuses,
|
(state: RootState) => state.statuses,
|
||||||
], (columnSettings, statusIds: string[], statuses) => {
|
], (columnSettings, statusIds: ImmutableOrderedSet<string>, statuses) => {
|
||||||
return statusIds.filter((id: string) => {
|
return statusIds.filter((id: string) => {
|
||||||
const status = statuses.get(id);
|
const status = statuses.get(id);
|
||||||
if (!status) return true;
|
if (!status) return true;
|
||||||
|
|
Ładowanie…
Reference in New Issue