diff --git a/app/soapbox/components/__tests__/scroll-top-button.test.js b/app/soapbox/components/__tests__/scroll-top-button.test.js new file mode 100644 index 000000000..89518b977 --- /dev/null +++ b/app/soapbox/components/__tests__/scroll-top-button.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { defineMessages } from 'react-intl'; + +import { render, screen } from '../../jest/test-helpers'; +import ScrollTopButton from '../scroll-top-button'; + +const messages = defineMessages({ + queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, +}); + +describe('', () => { + it('renders correctly', async() => { + render( + {}} + count={0} + message={messages.queue} + />, + ); + expect(screen.queryAllByRole('link')).toHaveLength(0); + + render( + {}} + count={1} + message={messages.queue} + />, + ); + expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument(); + + render( + {}} + count={9999999} + message={messages.queue} + />, + ); + expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument(); + }); +}); diff --git a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js deleted file mode 100644 index bc011d309..000000000 --- a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import { fromJS } from 'immutable'; -import React from 'react'; -import { defineMessages } from 'react-intl'; - -import { render, screen } from '../../jest/test-helpers'; -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}}' }, -}); - -describe('', () => { - it('renders correctly', async() => { - render( - {}} // eslint-disable-line react/jsx-no-bind - timelineId='home' - message={messages.queue} - />, - undefined, - { timelines: fromJS({ home: { totalQueuedItemsCount: 0 } }) }, - ); - expect(screen.queryAllByRole('link')).toHaveLength(0); - - render( - {}} // eslint-disable-line react/jsx-no-bind - timelineId='home' - message={messages.queue} - />, - undefined, - { timelines: fromJS({ home: { totalQueuedItemsCount: 1 } }) }, - ); - expect(screen.getByText(/Click to see\s+1\s+new post/, { hidden: true })).toBeInTheDocument(); - - render( - {}} // eslint-disable-line react/jsx-no-bind - timelineId='home' - message={messages.queue} - />, - undefined, - { timelines: fromJS({ home: { totalQueuedItemsCount: 9999999 } }) }, - ); - expect(screen.getByText(/10.*M/, { hidden: true })).toBeInTheDocument(); - }); -}); diff --git a/app/soapbox/components/timeline_queue_button_header.tsx b/app/soapbox/components/scroll-top-button.tsx similarity index 70% rename from app/soapbox/components/timeline_queue_button_header.tsx rename to app/soapbox/components/scroll-top-button.tsx index 292368571..5de90abb6 100644 --- a/app/soapbox/components/timeline_queue_button_header.tsx +++ b/app/soapbox/components/scroll-top-button.tsx @@ -5,27 +5,31 @@ 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'; +import { useSettings } from 'soapbox/hooks'; -interface ITimelineQueueButtonHeader { +interface IScrollTopButton { + /** Callback when clicked, and also when scrolled to the top. */ onClick: () => void, - timelineId: string, + /** Number of unread items. */ + count: number, + /** Message to display in the button (should contain a `{count}` value). */ message: MessageDescriptor, + /** Distance from the top of the screen (scrolling down) before the button appears. */ threshold?: number, + /** Distance from the top of the screen (scrolling up) before the action is triggered. */ autoloadThreshold?: number, } -const TimelineQueueButtonHeader: React.FC = ({ +/** Floating new post counter above timelines, clicked to scroll to top. */ +const ScrollTopButton: React.FC = ({ onClick, - timelineId, + count, 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; @@ -42,10 +46,10 @@ const TimelineQueueButtonHeader: React.FC = ({ } else { setScrolled(false); } - }, 150, { trailing: true }), []); + }, 150, { trailing: true }), [autoload, threshold, autoloadThreshold]); const scrollUp = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); + window.scrollTo({ top: 0 }); }; const handleClick: React.MouseEventHandler = () => { @@ -74,7 +78,7 @@ const TimelineQueueButtonHeader: React.FC = ({ {(count > 0) && ( - {intl.formatMessage(message, { count: shortNumberFormat(count) })} + {intl.formatMessage(message, { count })} )} @@ -82,4 +86,4 @@ const TimelineQueueButtonHeader: React.FC = ({ ); }; -export default TimelineQueueButtonHeader; +export default ScrollTopButton; diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index e546f2a45..8b803be66 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -9,6 +9,7 @@ import { useSettings } from 'soapbox/hooks'; import LoadMore from './load_more'; import { Spinner, Text } from './ui'; +/** Custom Viruoso component context. */ type Context = { itemClassName?: string, listClassName?: string, @@ -20,6 +21,7 @@ type SavedScrollPosition = { offset: number, } +/** Custom Virtuoso Item component representing a single scrollable item. */ // 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 @@ -27,6 +29,7 @@ const Item: Components['Item'] = ({ context, ...rest }) => (
); +/** Custom Virtuoso List component for the outer container. */ // Ensure the className winds up here const List: Components['List'] = React.forwardRef((props, ref) => { const { context, ...rest } = props; @@ -34,28 +37,47 @@ const List: Components['List'] = React.forwardRef((props, ref) => { }); interface IScrollableList extends VirtuosoProps { + /** Unique key to preserve the scroll position when navigating back. */ scrollKey?: string, + /** Pagination callback when the end of the list is reached. */ onLoadMore?: () => void, + /** Whether the data is currently being fetched. */ isLoading?: boolean, + /** Whether to actually display the loading state. */ showLoading?: boolean, + /** Whether we expect an additional page of data. */ hasMore?: boolean, + /** Additional element to display at the top of the list. */ prepend?: React.ReactNode, + /** Whether to display the prepended element. */ alwaysPrepend?: boolean, + /** Message to display when the list is loaded but empty. */ emptyMessage?: React.ReactNode, + /** Scrollable content. */ children: Iterable, + /** Callback when the list is scrolled to the top. */ onScrollToTop?: () => void, + /** Callback when the list is scrolled. */ onScroll?: () => void, + /** Placeholder component to render while loading. */ placeholderComponent?: React.ComponentType | React.NamedExoticComponent, + /** Number of placeholders to render while loading. */ placeholderCount?: number, + /** Pull to refresh callback. */ onRefresh?: () => Promise, + /** Extra class names on the Virtuoso element. */ className?: string, + /** Class names on each item container. */ itemClassName?: string, + /** `id` attribute on the Virtuoso element. */ id?: string, + /** CSS styles on the Virtuoso element. */ style?: React.CSSProperties, + /** Whether to use the window to scroll the content instead of Virtuoso's container. */ useWindowScroll?: boolean } -/** Legacy ScrollableList with Virtuoso for backwards-compatibility */ +/** Legacy ScrollableList with Virtuoso for backwards-compatibility. */ const ScrollableList = React.forwardRef(({ scrollKey, prepend = null, @@ -88,7 +110,7 @@ const ScrollableList = React.forwardRef(({ const topIndex = useRef(scrollData ? scrollData.index : 0); const topOffset = useRef(scrollData ? scrollData.offset : 0); - /** Normalized children */ + /** Normalized children. */ const elements = Array.from(children || []); const showPlaceholder = showLoading && Placeholder && placeholderCount > 0; @@ -129,7 +151,7 @@ const ScrollableList = React.forwardRef(({ }; }, []); - /* Render an empty state instead of the scrollable list */ + /* Render an empty state instead of the scrollable list. */ const renderEmpty = (): JSX.Element => { return (
@@ -146,7 +168,7 @@ const ScrollableList = React.forwardRef(({ ); }; - /** Render a single item */ + /** Render a single item. */ const renderItem = (_i: number, element: JSX.Element): JSX.Element => { if (showPlaceholder) { return ; @@ -192,7 +214,7 @@ const ScrollableList = React.forwardRef(({ return 0; }, [showLoading, initialTopMostItemIndex]); - /** Render the actual Virtuoso list */ + /** Render the actual Virtuoso list. */ const renderFeed = (): JSX.Element => ( (({ /> ); - /** Conditionally render inner elements */ + /** Conditionally render inner elements. */ const renderBody = (): JSX.Element => { if (isEmpty) { return renderEmpty(); diff --git a/app/soapbox/components/status-reply-mentions.tsx b/app/soapbox/components/status-reply-mentions.tsx index 05a90611e..9f8d890ae 100644 --- a/app/soapbox/components/status-reply-mentions.tsx +++ b/app/soapbox/components/status-reply-mentions.tsx @@ -1,4 +1,3 @@ -import { List as ImmutableList } from 'immutable'; import React from 'react'; import { FormattedList, FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -7,7 +6,7 @@ import { openModal } from 'soapbox/actions/modals'; import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; import { useAppDispatch } from 'soapbox/hooks'; -import type { Status } from 'soapbox/types/entities'; +import type { Account, Status } from 'soapbox/types/entities'; interface IStatusReplyMentions { status: Status, @@ -19,8 +18,10 @@ const StatusReplyMentions: React.FC = ({ status }) => { const handleOpenMentionsModal: React.MouseEventHandler = (e) => { e.stopPropagation(); + const account = status.account as Account; + dispatch(openModal('MENTIONS', { - username: status.getIn(['account', 'acct']), + username: account.acct, statusId: status.id, })); }; @@ -29,7 +30,7 @@ const StatusReplyMentions: React.FC = ({ status }) => { return null; } - const to = status.mentions || ImmutableList(); + const to = status.mentions; // The post is a reply, but it has no mentions. // Rare, but it can happen. @@ -46,14 +47,14 @@ const StatusReplyMentions: React.FC = ({ status }) => { // The typical case with a reply-to and a list of mentions. const accounts = to.slice(0, 2).map(account => ( - - @{account.get('username')} + + @{account.username} )).toArray(); if (to.size > 2) { accounts.push( - + , ); diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index a421f62a3..37e37f59d 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -14,24 +14,31 @@ import type { VirtuosoHandle } from 'react-virtuoso'; import type { IScrollableList } from 'soapbox/components/scrollable_list'; interface IStatusList extends Omit { + /** Unique key to preserve the scroll position when navigating back. */ scrollKey: string, + /** List of status IDs to display. */ statusIds: ImmutableOrderedSet, + /** Last _unfiltered_ status ID (maxId) for pagination. */ lastStatusId?: string, + /** Pinned statuses to show at the top of the feed. */ featuredStatusIds?: ImmutableOrderedSet, + /** Pagination callback when the end of the list is reached. */ onLoadMore?: (lastStatusId: string) => void, + /** Whether the data is currently being fetched. */ isLoading: boolean, + /** Whether the server did not return a complete page. */ isPartial?: boolean, + /** Whether we expect an additional page of data. */ hasMore: boolean, - prepend?: React.ReactNode, + /** Message to display when the list is loaded but empty. */ emptyMessage: React.ReactNode, - alwaysPrepend?: boolean, + /** ID of the timeline in Redux. */ timelineId?: string, - queuedItemSize?: number, - onScrollToTop?: () => void, - onScroll?: () => void, + /** Whether to display a gap or border between statuses in the list. */ divideType: 'space' | 'border', } +/** Feed of statuses, built atop ScrollableList. */ const StatusList: React.FC = ({ statusIds, lastStatusId, @@ -68,11 +75,11 @@ const StatusList: React.FC = ({ }; const handleLoadOlder = useCallback(debounce(() => { - const loadMoreID = lastStatusId || statusIds.last(); - if (onLoadMore && loadMoreID) { - onLoadMore(loadMoreID); + const maxId = lastStatusId || statusIds.last(); + if (onLoadMore && maxId) { + onLoadMore(maxId); } - }, 300, { leading: true }), []); + }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); const selectChild = (index: number) => { node.current?.scrollIntoView({ diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 672d1c5f6..e90125b39 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -1,16 +1,30 @@ import React from 'react'; +import { HotKeys } from 'react-hotkeys'; import { FormattedMessage } from 'react-intl'; import { Text } from 'soapbox/components/ui'; +interface ITombstone { + id: string, + onMoveUp: (statusId: string) => void, + onMoveDown: (statusId: string) => void, +} + /** Represents a deleted item. */ -const Tombstone: React.FC = () => { +const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { + const handlers = { + moveUp: () => onMoveUp(id), + moveDown: () => onMoveDown(id), + }; + return ( -
- - - -
+ +
+ + + +
+
); }; diff --git a/app/soapbox/features/community_timeline/index.js b/app/soapbox/features/community_timeline/index.js index 1ccc6076f..cb64b49b3 100644 --- a/app/soapbox/features/community_timeline/index.js +++ b/app/soapbox/features/community_timeline/index.js @@ -9,7 +9,7 @@ import { expandCommunityTimeline } from 'soapbox/actions/timelines'; import SubNavigation from 'soapbox/components/sub_navigation'; import { Column } from 'soapbox/components/ui'; -import StatusListContainer from '../ui/containers/status_list_container'; +import Timeline from '../ui/components/timeline'; import ColumnSettings from './containers/column_settings_container'; @@ -81,7 +81,7 @@ class CommunityTimeline extends React.PureComponent { return ( - { onSelected={handleSuggestion} /> - { const me = state.get('me'); @@ -90,7 +90,7 @@ class GroupTimeline extends React.PureComponent { )}
- ({ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, @@ -114,7 +114,7 @@ class HashtagTimeline extends React.PureComponent { return ( - } ) : ( - {
*/} - {filterBarContainer} -
} - = ({ params }) => { /> } - { renderTombstone(id: string) { return (
- +
); } @@ -635,6 +640,8 @@ class Status extends ImmutablePureComponent { index: this.props.ancestorsIds.size, offset: -80, }); + + setImmediate(() => this.status?.querySelector('a')?.focus()); } } diff --git a/app/soapbox/features/test_timeline/index.tsx b/app/soapbox/features/test_timeline/index.tsx index ace235b8f..024f38204 100644 --- a/app/soapbox/features/test_timeline/index.tsx +++ b/app/soapbox/features/test_timeline/index.tsx @@ -7,7 +7,7 @@ import { expandTimelineSuccess } from 'soapbox/actions/timelines'; import SubNavigation from 'soapbox/components/sub_navigation'; import { Column } from '../../components/ui'; -import StatusListContainer from '../ui/containers/status_list_container'; +import Timeline from '../ui/components/timeline'; const messages = defineMessages({ title: { id: 'column.test', defaultMessage: 'Test timeline' }, @@ -40,7 +40,7 @@ const TestTimeline: React.FC = () => { return ( - } diff --git a/app/soapbox/features/ui/containers/status_list_container.tsx b/app/soapbox/features/ui/components/timeline.tsx similarity index 79% rename from app/soapbox/features/ui/containers/status_list_container.tsx rename to app/soapbox/features/ui/components/timeline.tsx index 4dafbbf6d..af3ff8e8c 100644 --- a/app/soapbox/features/ui/containers/status_list_container.tsx +++ b/app/soapbox/features/ui/components/timeline.tsx @@ -5,8 +5,8 @@ import { defineMessages } from 'react-intl'; import { dequeueTimeline } from 'soapbox/actions/timelines'; import { scrollTopTimeline } from 'soapbox/actions/timelines'; +import ScrollTopButton from 'soapbox/components/scroll-top-button'; 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'; @@ -14,11 +14,13 @@ 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 { +interface ITimeline extends Omit { + /** ID of the timeline in Redux. */ timelineId: string, } -const StatusListContainer: React.FC = ({ +/** Scrollable list of statuses from a timeline in the Redux store. */ +const Timeline: React.FC = ({ timelineId, onLoadMore, ...rest @@ -31,6 +33,7 @@ const StatusListContainer: React.FC = ({ 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 totalQueuedItemsCount = useAppSelector(state => state.timelines.getIn([timelineId, 'totalQueuedItemsCount'])); const handleDequeueTimeline = () => { dispatch(dequeueTimeline(timelineId, onLoadMore)); @@ -38,22 +41,23 @@ const StatusListContainer: React.FC = ({ const handleScrollToTop = useCallback(debounce(() => { dispatch(scrollTopTimeline(timelineId, true)); - }, 100), []); + }, 100), [timelineId]); const handleScroll = useCallback(debounce(() => { dispatch(scrollTopTimeline(timelineId, false)); - }, 100), []); + }, 100), [timelineId]); return ( <> - = ({ ); }; -export default StatusListContainer; +export default Timeline;