From ba0b4851f66e622b6f4699fde7e70a2d02f6b545 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 19 May 2022 16:09:22 -0500 Subject: [PATCH 01/21] ScrollableList: save and restore scroll position --- app/soapbox/components/scrollable_list.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index cc4b55867..3fb874eea 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; @@ -62,11 +62,16 @@ const ScrollableList = React.forwardRef(({ placeholderComponent: Placeholder, placeholderCount = 0, initialTopMostItemIndex = 0, - scrollerRef, }, ref) => { const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); + // Preserve scroll position + const scrollPositionKey = `soapbox:scrollPosition:${location.pathname}`; + const scrollPosition = Number(sessionStorage.getItem(scrollPositionKey)); + const initialScrollTop = useRef(scrollPosition); + const scroller = useRef(null); + /** Normalized children */ const elements = Array.from(children || []); @@ -85,6 +90,15 @@ const ScrollableList = React.forwardRef(({ data.push(); } + useEffect(() => { + sessionStorage.removeItem(scrollPositionKey); + + // On unmount, save the scroll position. + return () => { + sessionStorage.setItem(scrollPositionKey, String(document.documentElement.scrollTop)); + }; + }, []); + /* Render an empty state instead of the scrollable list */ const renderEmpty = (): JSX.Element => { return ( @@ -130,6 +144,7 @@ const ScrollableList = React.forwardRef(({ (({ Item, Footer: loadMore, }} - scrollerRef={scrollerRef} + scrollerRef={c => scroller.current = c} /> ); From 533c356d601b006a91000bbd52b1db06acbd6483 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 19 May 2022 20:03:52 -0500 Subject: [PATCH 02/21] ScrollableList: preserve index rather than scrollTop --- app/soapbox/components/scrollable_list.tsx | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 3fb874eea..db389ccc3 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange } from 'react-virtuoso'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { useSettings } from 'soapbox/hooks'; @@ -66,10 +66,10 @@ const ScrollableList = React.forwardRef(({ const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); - // Preserve scroll position - const scrollPositionKey = `soapbox:scrollPosition:${location.pathname}`; - const scrollPosition = Number(sessionStorage.getItem(scrollPositionKey)); - const initialScrollTop = useRef(scrollPosition); + // Preserve scroll index + const scrollIndexKey = `soapbox:scrollIndex:${location.pathname}`; + const scrollIndex = Number(sessionStorage.getItem(scrollIndexKey)); + const initialIndex = useRef(scrollIndex); const scroller = useRef(null); /** Normalized children */ @@ -91,12 +91,7 @@ const ScrollableList = React.forwardRef(({ } useEffect(() => { - sessionStorage.removeItem(scrollPositionKey); - - // On unmount, save the scroll position. - return () => { - sessionStorage.setItem(scrollPositionKey, String(document.documentElement.scrollTop)); - }; + sessionStorage.removeItem(scrollIndexKey); }, []); /* Render an empty state instead of the scrollable list */ @@ -139,19 +134,23 @@ const ScrollableList = React.forwardRef(({ } }; + const handleRangeChanged = (range: ListRange) => { + sessionStorage.setItem(scrollIndexKey, String(range.startIndex)); + }; + /** Render the actual Virtuoso list */ const renderFeed = (): JSX.Element => ( isScrolling && onScroll && onScroll()} itemContent={renderItem} - initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex} + initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex || initialIndex.current} + rangeChanged={handleRangeChanged} context={{ listClassName: className, itemClassName, From 8052d5040ac8705c07aa5a667ac0b3d9c4f94887 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jun 2022 18:00:09 -0500 Subject: [PATCH 03/21] ScrollableList: delete unused scrollerRef --- app/soapbox/components/scrollable_list.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 4c9ad8b5d..5fd1843c4 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -76,7 +76,6 @@ const ScrollableList = React.forwardRef(({ const scrollIndexKey = `soapbox:scrollIndex:${location.pathname}`; const scrollIndex = Number(sessionStorage.getItem(scrollIndexKey)); const initialIndex = useRef(scrollIndex); - const scroller = useRef(null); /** Normalized children */ const elements = Array.from(children || []); @@ -171,7 +170,6 @@ const ScrollableList = React.forwardRef(({ Item, Footer: loadMore, }} - scrollerRef={c => scroller.current = c} /> ); From 28bd9b0f4b76515248d1252fb26ceba746306668 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jun 2022 18:00:35 -0500 Subject: [PATCH 04/21] StatusList: don't dequeue timeline on mount --- app/soapbox/components/status_list.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/soapbox/components/status_list.js b/app/soapbox/components/status_list.js index 5d08ab1a2..13300a29a 100644 --- a/app/soapbox/components/status_list.js +++ b/app/soapbox/components/status_list.js @@ -46,10 +46,6 @@ export default class StatusList extends ImmutablePureComponent { divideType: 'border', } - componentDidMount() { - this.handleDequeueTimeline(); - } - getFeaturedStatusCount = () => { return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; } From 3d605913e8dc7226c02a61aad79d4f9ba49a2a11 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jun 2022 18:47:07 -0500 Subject: [PATCH 05/21] Preserve scroll position perfectly --- app/soapbox/components/scrollable_list.tsx | 38 +++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 5fd1843c4..18dd1e987 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -12,6 +12,11 @@ type Context = { listClassName?: string, } +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 @@ -72,10 +77,11 @@ const ScrollableList = React.forwardRef(({ const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); - // Preserve scroll index - const scrollIndexKey = `soapbox:scrollIndex:${location.pathname}`; - const scrollIndex = Number(sessionStorage.getItem(scrollIndexKey)); - const initialIndex = useRef(scrollIndex); + // Preserve scroll position + const scrollDataKey = `soapbox:scrollData:${location.pathname}`; + const scrollData: SavedScrollPosition | null = JSON.parse(sessionStorage.getItem(scrollDataKey)!); + const topIndex = useRef(scrollData ? scrollData.index : 0); + const topOffset = useRef(scrollData ? scrollData.offset : 0); /** Normalized children */ const elements = Array.from(children || []); @@ -95,8 +101,22 @@ const ScrollableList = React.forwardRef(({ data.push(); } + const handleScroll = () => { + const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`); + if (node) { + topOffset.current = node.getBoundingClientRect().top * -1; + } + }; + useEffect(() => { - sessionStorage.removeItem(scrollIndexKey); + document.addEventListener('scroll', handleScroll); + sessionStorage.removeItem(scrollDataKey); + + return () => { + const data = { 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 */ @@ -139,8 +159,8 @@ const ScrollableList = React.forwardRef(({ } }; - const handleRangeChanged = (range: ListRange) => { - sessionStorage.setItem(scrollIndexKey, String(range.startIndex)); + const handleRangeChange = (range: ListRange) => { + topIndex.current = range.startIndex; }; /** Render the actual Virtuoso list */ @@ -155,8 +175,8 @@ const ScrollableList = React.forwardRef(({ endReached={handleEndReached} isScrolling={isScrolling => isScrolling && onScroll && onScroll()} itemContent={renderItem} - initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex || initialIndex.current} - rangeChanged={handleRangeChanged} + initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex || (scrollData ? { align: 'start', index: scrollData.index, offset: scrollData.offset } : 0)} + rangeChanged={handleRangeChange} style={style} context={{ listClassName: className, From 577c39141758b01538bbcd8a9a5a76d570827abf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jun 2022 19:36:55 -0500 Subject: [PATCH 06/21] ScrollableList: fix desync scroll position, add 200px overscan --- app/soapbox/components/scrollable_list.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 18dd1e987..9f5af5601 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -161,6 +161,7 @@ const ScrollableList = React.forwardRef(({ const handleRangeChange = (range: ListRange) => { topIndex.current = range.startIndex; + handleScroll(); }; /** Render the actual Virtuoso list */ @@ -190,6 +191,7 @@ const ScrollableList = React.forwardRef(({ Item, Footer: loadMore, }} + overscan={{ main: 200, reverse: 200 }} /> ); From 697c028c4ac1dc4aa943efb1b30803b134dc491b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 1 Jun 2022 20:47:28 -0500 Subject: [PATCH 07/21] TimelineQueueButtonHeader: convert to tsx --- .../timeline_queue_button_header.js | 119 ------------------ .../timeline_queue_button_header.tsx | 83 ++++++++++++ 2 files changed, 83 insertions(+), 119 deletions(-) delete mode 100644 app/soapbox/components/timeline_queue_button_header.js create mode 100644 app/soapbox/components/timeline_queue_button_header.tsx 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..e0d65fc67 --- /dev/null +++ b/app/soapbox/components/timeline_queue_button_header.tsx @@ -0,0 +1,83 @@ +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 { useSettings } from 'soapbox/hooks'; + +interface ITimelineQueueButtonHeader { + onClick: () => void, + count?: number, + message: MessageDescriptor, + threshold?: number, + autoloadThreshold?: number, +} + +const TimelineQueueButtonHeader: React.FC = ({ + onClick, + count = 0, + message, + threshold = 400, + autoloadThreshold = 50, +}) => { + const intl = useIntl(); + const settings = useSettings(); + + 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; From d0d9c0b4605906cae79ad60f1750b3388ac66cd0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 2 Jun 2022 13:32:08 -0500 Subject: [PATCH 08/21] StatusList: convert to TSX --- app/soapbox/components/load_gap.tsx | 4 +- app/soapbox/components/scrollable_list.tsx | 6 +- app/soapbox/components/status.tsx | 8 +- app/soapbox/components/status_list.js | 236 --------------------- app/soapbox/components/status_list.tsx | 233 ++++++++++++++++++++ 5 files changed, 243 insertions(+), 244 deletions(-) delete mode 100644 app/soapbox/components/status_list.js create mode 100644 app/soapbox/components/status_list.tsx diff --git a/app/soapbox/components/load_gap.tsx b/app/soapbox/components/load_gap.tsx index b784c871d..a21e64749 100644 --- a/app/soapbox/components/load_gap.tsx +++ b/app/soapbox/components/load_gap.tsx @@ -9,8 +9,8 @@ const messages = defineMessages({ interface ILoadGap { disabled?: boolean, - maxId: string, - onClick: (id: string) => void, + maxId: string | null, + onClick: (id: string | null) => void, } const LoadGap: React.FC = ({ disabled, maxId, onClick }) => { diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 9f5af5601..a2df04005 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -36,13 +36,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, @@ -184,7 +184,7 @@ const ScrollableList = React.forwardRef(({ itemClassName, }} components={{ - Header: () => prepend, + Header: () => <>{prepend}, ScrollSeekPlaceholder: Placeholder as any, EmptyPlaceholder: () => renderEmpty(), List, diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 370deb6de..a8e684a3a 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,7 @@ interface IStatus extends RouteComponentProps { allowedEmoji: ImmutableList, focusable: boolean, history: History, - featured?: string, + featured?: boolean, } interface IStatusState { diff --git a/app/soapbox/components/status_list.js b/app/soapbox/components/status_list.js deleted file mode 100644 index 13300a29a..000000000 --- a/app/soapbox/components/status_list.js +++ /dev/null @@ -1,236 +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', - } - - 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..7500f6a79 --- /dev/null +++ b/app/soapbox/components/status_list.tsx @@ -0,0 +1,233 @@ +import classNames from 'classnames'; +import { debounce } from 'lodash'; +import React, { useRef, useCallback } from 'react'; +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'; + +import type { + Map as ImmutableMap, + OrderedSet as ImmutableOrderedSet, +} from 'immutable'; +import type { VirtuosoHandle } from 'react-virtuoso'; + +const messages = defineMessages({ + queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, +}); + +interface IStatusList { + scrollKey: string, + statusIds: ImmutableOrderedSet, + lastStatusId: string, + featuredStatusIds?: ImmutableOrderedSet, + onLoadMore: (lastStatusId: string | null) => void, + isLoading: boolean, + isPartial: boolean, + hasMore: boolean, + prepend: React.ReactNode, + emptyMessage: React.ReactNode, + alwaysPrepend: boolean, + timelineId: string, + queuedItemSize: number, + onDequeueTimeline: (timelineId: string) => void, + totalQueuedItemsCount: number, + group?: ImmutableMap, + withGroupAdmin: boolean, + onScrollToTop: () => void, + onScroll: () => void, + divideType: 'space' | 'border', +} + +const StatusList: React.FC = ({ + statusIds, + lastStatusId, + featuredStatusIds, + divideType = 'border', + onLoadMore, + timelineId, + totalQueuedItemsCount, + onDequeueTimeline, + isLoading, + isPartial, + withGroupAdmin, + group, + ...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 (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 handleDequeueTimeline = () => { + if (!onDequeueTimeline || !timelineId) return; + onDequeueTimeline(timelineId); + }; + + const renderLoadGap = (index: number) => { + const ids = statusIds.toList(); + + return ( + 0 ? ids.get(index - 1) || null : null} + onClick={onLoadMore} + /> + ); + }; + + const renderStatus = (statusId: string) => { + return ( + // @ts-ignore + + ); + }; + + const renderPendingStatus = (statusId: string) => { + const idempotencyKey = statusId.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderFeaturedStatuses = (): JSX.Element[] => { + if (!featuredStatusIds) return []; + + return featuredStatusIds.toArray().map(statusId => ( + // @ts-ignore + + )); + }; + + const renderStatuses = (): JSX.Element[] => { + 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; From a185ad45cdae71eb938084a025aad1848f736654 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 2 Jun 2022 14:00:35 -0500 Subject: [PATCH 09/21] StatusList TypeScript fixes --- app/soapbox/components/load_gap.tsx | 4 +- app/soapbox/components/scrollable_list.tsx | 1 + app/soapbox/components/status.tsx | 1 + app/soapbox/components/status_list.tsx | 41 +++++++++++-------- app/soapbox/features/bookmarks/index.tsx | 7 ++-- .../conversations/components/conversation.tsx | 2 +- .../notifications/components/notification.tsx | 2 +- 7 files changed, 32 insertions(+), 26 deletions(-) diff --git a/app/soapbox/components/load_gap.tsx b/app/soapbox/components/load_gap.tsx index a21e64749..b784c871d 100644 --- a/app/soapbox/components/load_gap.tsx +++ b/app/soapbox/components/load_gap.tsx @@ -9,8 +9,8 @@ const messages = defineMessages({ interface ILoadGap { disabled?: boolean, - maxId: string | null, - onClick: (id: string | null) => void, + maxId: string, + onClick: (id: string) => void, } const LoadGap: React.FC = ({ disabled, maxId, onClick }) => { diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index a2df04005..559b67b62 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -212,3 +212,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 a8e684a3a..2b1325a60 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -101,6 +101,7 @@ interface IStatus extends RouteComponentProps { focusable: boolean, history: History, featured?: boolean, + withDismiss?: boolean, } interface IStatusState { diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 7500f6a79..79d6b7a44 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -16,31 +16,32 @@ import type { OrderedSet as ImmutableOrderedSet, } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; +import type { IScrollableList } from 'soapbox/components/scrollable_list'; const messages = defineMessages({ queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, }); -interface IStatusList { +interface IStatusList extends Omit { scrollKey: string, statusIds: ImmutableOrderedSet, - lastStatusId: string, + lastStatusId?: string, featuredStatusIds?: ImmutableOrderedSet, - onLoadMore: (lastStatusId: string | null) => void, + onLoadMore?: (lastStatusId: string) => void, isLoading: boolean, - isPartial: boolean, + isPartial?: boolean, hasMore: boolean, - prepend: React.ReactNode, + prepend?: React.ReactNode, emptyMessage: React.ReactNode, - alwaysPrepend: boolean, - timelineId: string, - queuedItemSize: number, - onDequeueTimeline: (timelineId: string) => void, - totalQueuedItemsCount: number, + alwaysPrepend?: boolean, + timelineId?: string, + queuedItemSize?: number, + onDequeueTimeline?: (timelineId: string) => void, + totalQueuedItemsCount?: number, group?: ImmutableMap, - withGroupAdmin: boolean, - onScrollToTop: () => void, - onScroll: () => void, + withGroupAdmin?: boolean, + onScrollToTop?: () => void, + onScroll?: () => void, divideType: 'space' | 'border', } @@ -85,7 +86,7 @@ const StatusList: React.FC = ({ const handleLoadOlder = useCallback(debounce(() => { const loadMoreID = lastStatusId || statusIds.last(); - if (loadMoreID) { + if (onLoadMore && loadMoreID) { onLoadMore(loadMoreID); } }, 300, { leading: true }), []); @@ -108,12 +109,16 @@ const StatusList: React.FC = ({ 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 ( 0 ? ids.get(index - 1) || null : null} + maxId={prevId!} onClick={onLoadMore} /> ); @@ -143,7 +148,7 @@ const StatusList: React.FC = ({ ); }; - const renderFeaturedStatuses = (): JSX.Element[] => { + const renderFeaturedStatuses = (): React.ReactNode[] => { if (!featuredStatusIds) return []; return featuredStatusIds.toArray().map(statusId => ( @@ -159,7 +164,7 @@ const StatusList: React.FC = ({ )); }; - const renderStatuses = (): JSX.Element[] => { + const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toArray().map((statusId, index) => { if (statusId === null) { 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