pl-fe: Migrate back to react-virtuoso

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-03-13 17:32:07 +01:00
parent cf6ba0ce75
commit 8da53b3006
53 changed files with 512 additions and 310 deletions

View File

@ -127,6 +127,7 @@
"react-sparklines": "^1.7.0",
"react-sticky-box": "^2.0.5",
"react-swipeable-views": "^0.14.0",
"react-virtuoso": "^4.12.5",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.1",

View File

@ -1,16 +1,43 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { useVirtualizer, useWindowVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import React, { useEffect, useMemo, useRef } from 'react';
import debounce from 'lodash/debounce';
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 LoadMore from 'pl-fe/components/load-more';
import Card from 'pl-fe/components/ui/card';
import Spinner from 'pl-fe/components/ui/spinner';
import { useSettings } from 'pl-fe/hooks/use-settings';
const measureElement = (element: Element) => element.scrollHeight;
/** Custom Viruoso component context. */
type Context = {
itemClassName?: string;
listClassName?: string;
}
interface IScrollableListBase {
/** Scroll position saved in sessionStorage. */
type SavedScrollPosition = {
index: number;
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
const Item: Components<JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
<div className={context?.itemClassName} {...rest} />
);
/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} className={context?.listClassName} {...rest} />;
});
interface IScrollableList extends VirtuosoProps<any, any> {
/** 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. */
@ -29,42 +56,32 @@ interface IScrollableListBase {
emptyMessageCard?: boolean;
/** Scrollable content. */
children: Iterable<React.ReactNode>;
/** Callback when the list is scrolled to the top. */
onScrollToTop?: () => void;
/** Callback when the list is scrolled. */
onScroll?: (startIndex?: number, endIndex?: number) => void;
onScroll?: () => void;
/** Placeholder component to render while loading. */
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
/** Number of placeholders to render while loading. */
placeholderCount?: number;
/** Extra class names on the Virtuoso element. */
className?: string;
/** Extra class names on the list element. */
listClassName?: string;
/** Class names on each item container. */
itemClassName?: string;
/** Extra class names on the LoadMore element */
loadMoreClassName?: string;
/** `id` attribute on the parent element. */
/** `id` attribute on the Virtuoso element. */
id?: string;
/** Initial item index to scroll to. */
initialIndex?: number;
/** Estimated size for items. */
estimatedSize?: number;
/** Align the items to the bottom of the list. */
alignToBottom?: boolean;
}
interface IScrollableListWithContainer extends IScrollableListBase {
/** Extra class names on the container element. */
className?: string;
/** CSS styles on the container element. */
/** 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;
}
interface IScrollableListWithoutContainer extends IScrollableListBase {
parentRef: React.RefObject<HTMLElement>;
}
type IScrollableList = IScrollableListWithContainer | IScrollableListWithoutContainer;
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(({
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
scrollKey,
prepend = null,
alwaysPrepend,
children,
@ -73,7 +90,9 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
emptyMessageCard = true,
showLoading,
onScroll,
onScrollToTop,
onLoadMore,
className,
listClassName,
itemClassName,
loadMoreClassName,
@ -81,75 +100,59 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
hasMore,
placeholderComponent: Placeholder,
placeholderCount = 0,
initialIndex,
estimatedSize = 300,
alignToBottom,
...props
initialTopMostItemIndex = 0,
style = {},
useWindowScroll = true,
...params
}, ref) => {
const listRef = useRef<HTMLDivElement>(null);
const history = useHistory();
const { autoloadMore } = useSettings();
// Preserve scroll position
const scrollDataKey = `plfe:scrollData:${scrollKey}`;
const scrollData: SavedScrollPosition | null = useMemo(() => JSON.parse(sessionStorage.getItem(scrollDataKey)!), [scrollDataKey]);
const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);
/** Normalized children. */
const elements = Array.from(children || []);
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
// NOTE: We are doing some trickery to load a feed of placeholders
// Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case
const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
const virtualizer = 'parentRef' in props ? useVirtualizer({
count: data.length + (hasMore ? 1 : 0),
overscan: 1,
estimateSize: () => estimatedSize,
getScrollElement: () => props.parentRef.current,
measureElement,
}) : useWindowVirtualizer({
count: data.length + (hasMore ? 1 : 0),
overscan: 1,
estimateSize: () => estimatedSize,
scrollMargin: listRef.current ? listRef.current.getBoundingClientRect().top + window.scrollY : 0,
measureElement,
});
// Add a placeholder at the bottom for loading
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
if (hasMore && (autoloadMore || isLoading) && Placeholder) {
data.push(<Placeholder />);
} else if (hasMore && (autoloadMore || isLoading)) {
data.push(<Spinner />);
}
useEffect(() => {
if (typeof ref === 'function') ref(virtualizer); else if (ref !== null) ref.current = virtualizer;
}, [virtualizer]);
const range = virtualizer.calculateRange();
useEffect(() => {
if (showLoading) return;
if (typeof initialIndex === 'number') {
const targetIndex = (initialIndex === 0) ? initialIndex : initialIndex + 1;
virtualizer.scrollToIndex(targetIndex);
setTimeout(() => {
virtualizer.scrollToIndex(targetIndex);
}, 1);
}
}, [showLoading, initialIndex]);
useEffect(() => {
onScroll?.(range?.startIndex, range?.endIndex);
}, [range?.startIndex, range?.endIndex]);
useEffect(() => {
if (onLoadMore && range?.endIndex === data.length && !showLoading && autoloadMore && hasMore) {
onLoadMore();
}
}, [range?.endIndex]);
const loadMore = useMemo(() => {
if (autoloadMore || !hasMore || !onLoadMore) {
return null;
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 {
const button = <LoadMore className='mt-4' visible={!isLoading} onClick={onLoadMore} />;
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
return button;
topOffset.current = 0;
}
}, [autoloadMore, hasMore, isLoading]);
}, 150, { trailing: true }), []);
useEffect(() => {
document.addEventListener('scroll', handleScroll);
sessionStorage.removeItem(scrollDataKey);
return () => {
if (scrollKey) {
const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
}
document.removeEventListener('scroll', handleScroll);
};
}, []);
/* Render an empty state instead of the scrollable list. */
const renderEmpty = (): JSX.Element => (
@ -170,65 +173,100 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
</div>
);
const renderItem = (index: number): JSX.Element => {
const PlaceholderComponent = Placeholder || Spinner;
if (alignToBottom && hasMore ? index === 0 : index === data.length) return (isLoading) ? <PlaceholderComponent /> : loadMore || <div className='h-4' />;
if (showPlaceholder) return <PlaceholderComponent />;
return data[alignToBottom && hasMore ? index - 1 : index];
/** Render a single item. */
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
if (showPlaceholder) {
return <Placeholder />;
} else {
return element;
}
};
const virtualItems = virtualizer.getVirtualItems();
const handleEndReached = () => {
if (autoloadMore && hasMore && onLoadMore) {
onLoadMore();
}
};
const body = (
<div
ref={listRef}
id={'parentRef' in props ? id : undefined}
className={listClassName}
style={{
height: (!showLoading || showPlaceholder) && data.length ? virtualizer.getTotalSize() : undefined,
width: '100%',
position: 'relative',
}}
>
{(!showLoading || showPlaceholder) && data.length ? (
<>
{prepend}
{virtualItems.map((item) => (
<div
className={item.index === data.length ? '' : itemClassName}
key={item.key as number}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
width: '100%',
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
>
{renderItem(item.index)}
</div>
))}
</>
) : renderEmpty()}
</div>
);
const loadMore = () => {
if (autoloadMore || !hasMore || !onLoadMore) {
return null;
} else {
const button = <LoadMore visible={!isLoading} onClick={onLoadMore} />;
if ('parentRef' in props) return body;
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
return button;
}
};
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) {
if (typeof initialTopMostItemIndex === 'number') {
return {
align: 'start',
index: initialTopMostItemIndex,
offset: 60,
};
}
return initialTopMostItemIndex;
}
if (scrollData && history.action === 'POP') {
return {
align: 'start',
index: scrollData.index,
offset: scrollData.offset,
};
}
return 0;
}, [showLoading, initialTopMostItemIndex]);
return (
<div
<Virtuoso
{...params}
ref={ref}
id={id}
className={clsx(props.className, 'w-full')}
style={props.style}
>
{body}
</div>
useWindowScroll={useWindowScroll}
data={data}
totalCount={data.length}
startReached={onScrollToTop}
endReached={handleEndReached}
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
itemContent={renderItem}
initialTopMostItemIndex={initialIndex}
rangeChanged={handleRangeChange}
className={className}
style={style}
context={{
listClassName,
itemClassName,
}}
components={{
Header: () => <>{prepend}</>,
ScrollSeekPlaceholder: Placeholder as any,
EmptyPlaceholder: () => renderEmpty(),
List,
Item,
Footer: loadMore,
}}
/>
);
});
export {
type IScrollableList,
type IScrollableListWithContainer,
type IScrollableListWithoutContainer,
ScrollableList as default,
};

View File

@ -1,17 +1,19 @@
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { useCallback, useMemo } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import LoadGap from 'pl-fe/components/load-gap';
import ScrollableList, { type IScrollableListWithContainer } from 'pl-fe/components/scrollable-list';
import ScrollableList, { type IScrollableList } from 'pl-fe/components/scrollable-list';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import StatusContainer from 'pl-fe/containers/status-container';
import PlaceholderStatus from 'pl-fe/features/placeholder/components/placeholder-status';
import PendingStatus from 'pl-fe/features/ui/components/pending-status';
interface IStatusList extends Omit<IScrollableListWithContainer, 'onLoadMore' | 'children'> {
import type { VirtuosoHandle } from 'react-virtuoso';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** Unique key to preserve the scroll position when navigating back. */
scrollKey: string;
/** List of status IDs to display. */
@ -29,7 +31,7 @@ interface IStatusList extends Omit<IScrollableListWithContainer, 'onLoadMore' |
/** Whether we expect an additional page of data. */
hasMore: boolean;
/** Message to display when the list is loaded but empty. */
emptyMessage: React.ReactNode;
emptyMessage?: React.ReactNode;
/** ID of the timeline in Redux. */
timelineId?: string;
/** Whether to display a gap or border between statuses in the list. */
@ -52,6 +54,8 @@ const StatusList: React.FC<IStatusList> = ({
className,
...other
}) => {
const node = useRef<VirtuosoHandle>(null);
const getFeaturedStatusCount = () => featuredStatusIds?.length || 0;
const getCurrentStatusIndex = (id: string, featured: boolean): number => {
@ -84,6 +88,14 @@ const StatusList: React.FC<IStatusList> = ({
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
const renderLoadGap = (index: number) => {
@ -200,10 +212,10 @@ const StatusList: React.FC<IStatusList> = ({
onLoadMore={handleLoadOlder}
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
placeholderCount={20}
className={className}
ref={node}
listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
'divide-none': divideType !== 'border',
})}
}, className)}
itemClassName={clsx({
'pb-3': divideType !== 'border',
})}

View File

@ -117,6 +117,7 @@ const Announcements: React.FC = () => {
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
</Button>
<ScrollableList
scrollKey='announcements'
emptyMessage={emptyMessage}
itemClassName='py-3 first:pt-0 last:pb-0'
isLoading={isLoading}

View File

@ -132,6 +132,7 @@ const Domains: React.FC = () => {
</Button>
{domains && (
<ScrollableList
scrollKey='domains'
emptyMessage={emptyMessage}
itemClassName='py-3 first:pt-0 last:pb-0'
isLoading={isFetching}

View File

@ -33,6 +33,7 @@ const ModerationLog = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='moderationLog'
isLoading={isLoading}
showLoading={showLoading}
emptyMessage={intl.formatMessage(messages.emptyMessage)}

View File

@ -126,6 +126,7 @@ const Relays: React.FC = () => {
{relays && (
<ScrollableList
scrollKey='relays'
emptyMessage={emptyMessage}
itemClassName='py-3 first:pt-0 last:pb-0'
isLoading={isFetching}

View File

@ -97,6 +97,7 @@ const Rules: React.FC = () => {
<FormattedMessage id='admin.rules.action' defaultMessage='Create rule' />
</Button>
<ScrollableList
scrollKey='rules'
emptyMessage={emptyMessage}
itemClassName='py-3 first:pt-0 last:pb-0'
isLoading={isLoading}

View File

@ -33,6 +33,7 @@ const AwaitingApproval: React.FC = () => {
return (
<ScrollableList
scrollKey='awaitingApproval'
isLoading={isLoading}
showLoading={showLoading}
emptyMessage={intl.formatMessage(messages.emptyMessage)}

View File

@ -32,6 +32,7 @@ const Reports: React.FC = () => {
return (
<ScrollableList
scrollKey='adminReports'
isLoading={isLoading}
showLoading={showLoading}
emptyMessage={intl.formatMessage(messages.emptyMessage)}

View File

@ -51,6 +51,7 @@ const UserIndex: React.FC = () => {
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
<ScrollableList
scrollKey='userIndex'
hasMore={hasMore}
isLoading={isLoading}
showLoading={showLoading}

View File

@ -73,7 +73,10 @@ const Aliases = () => {
<CardTitle title={intl.formatMessage(messages.subheading_aliases)} />
</CardHeader>
<div className='flex-1'>
<ScrollableList emptyMessage={emptyMessage}>
<ScrollableList
scrollKey='aliases'
emptyMessage={emptyMessage}
>
{aliases.map((alias, i) => (
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
<div>

View File

@ -34,6 +34,7 @@ const Blocks: React.FC = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='blocks'
onLoadMore={fetchNextPage}
hasMore={hasNextPage}
emptyMessage={emptyMessage}

View File

@ -1,8 +1,9 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import PullToRefresh from 'pl-fe/components/pull-to-refresh';
import ScrollableList from 'pl-fe/components/scrollable-list';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import PlaceholderChat from 'pl-fe/features/placeholder/components/placeholder-chat';
import { useChats } from 'pl-fe/queries/chats';
@ -11,11 +12,10 @@ import ChatListItem from './chat-list-item';
interface IChatList {
onClickChat: (chat: any) => void;
parentRef: React.RefObject<HTMLElement>;
topOffset: number;
useWindowScroll?: boolean;
}
const ChatList: React.FC<IChatList> = ({ onClickChat, parentRef, topOffset }) => {
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false }) => {
const { chatsQuery: { data: chats, isFetching, hasNextPage, fetchNextPage, refetch } } = useChats();
const [isNearBottom, setNearBottom] = useState<boolean>(false);
@ -44,47 +44,42 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, parentRef, topOffset }) =>
};
return (
<>
<div className='relative h-full'>
<PullToRefresh onRefresh={handleRefresh}>
<ScrollableList
onScroll={(top, bottom) => {
setNearTop(top === 0);
setNearBottom(bottom === chats?.length);
}}
itemClassName='px-2'
emptyMessage={renderEmpty()}
placeholderComponent={PlaceholderChat}
placeholderCount={3}
hasMore={hasNextPage}
onLoadMore={handleLoadMore}
estimatedSize={64}
parentRef={parentRef}
loadMoreClassName='mx-4 mb-4'
>
{(chats || []).map(chat => (
<ChatListItem key={chat.id} chat={chat} onClick={onClickChat} />
))}
</ScrollableList>
</PullToRefresh>
<div className='relative h-full'>
<PullToRefresh onRefresh={handleRefresh}>
<Virtuoso
atTopStateChange={(atTop) => setNearTop(atTop)}
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
useWindowScroll={useWindowScroll}
data={chats}
endReached={handleLoadMore}
itemContent={(_index, chat) => (
<div className='px-2'>
<ChatListItem chat={chat} onClick={onClickChat} />
</div>
)}
components={{
ScrollSeekPlaceholder: () => <PlaceholderChat />,
Footer: () => hasNextPage ? <Spinner withText={false} /> : null,
EmptyPlaceholder: renderEmpty,
}}
/>
</PullToRefresh>
</div>
<div
className={clsx('pointer-events-none absolute inset-x-0 flex justify-center rounded-t-lg bg-gradient-to-b from-white to-transparent pb-12 pt-8 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearTop,
'opacity-100 black:opacity-50': !isNearTop,
})}
style={{
top: topOffset,
}}
/>
<div
className={clsx('pointer-events-none absolute inset-x-0 bottom-0 flex justify-center rounded-b-lg bg-gradient-to-t from-white to-transparent pb-8 pt-12 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearBottom,
'opacity-100 black:opacity-50': !isNearBottom,
})}
/>
</>
<>
<div
className={clsx('pointer-events-none absolute inset-x-0 flex justify-center rounded-t-lg bg-gradient-to-b from-white to-transparent pb-12 pt-8 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearTop,
'opacity-100 black:opacity-50': !isNearTop,
})}
/>
<div
className={clsx('pointer-events-none absolute inset-x-0 bottom-0 flex justify-center rounded-b-lg bg-gradient-to-t from-white to-transparent pb-8 pt-12 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearBottom,
'opacity-100 black:opacity-50': !isNearBottom,
})}
/>
</>
</div>
);
};

View File

@ -1,5 +1,6 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'pl-fe/api';
import { ChatContext } from 'pl-fe/contexts/chat-context';
@ -63,10 +64,11 @@ const store = {
};
const renderComponentWithChatContext = () => render(
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>,
undefined,
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>
</VirtuosoMockContext.Provider>,
store,
);

View File

@ -1,10 +1,11 @@
import React, { useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import ScrollableList from 'pl-fe/components/scrollable-list';
import Avatar from 'pl-fe/components/ui/avatar';
import Button from 'pl-fe/components/ui/button';
import Divider from 'pl-fe/components/ui/divider';
import Spinner from 'pl-fe/components/ui/spinner';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
import { Entities } from 'pl-fe/entity-store/entities';
@ -39,6 +40,28 @@ const timeChange = (prev: Pick<ChatMessageEntity, 'created_at'>, curr: Pick<Chat
return null;
};
const START_INDEX = 10000;
const List: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props;
return <div ref={ref} {...rest} className='mb-2' />;
});
const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => {
const { style, context, ...rest } = props;
return (
<div
{...rest}
ref={ref}
style={{
...style,
scrollbarGutter: 'stable',
}}
/>
);
});
interface IChatMessageList {
/** Chat the messages are being rendered from. */
chat: Chat;
@ -48,7 +71,8 @@ interface IChatMessageList {
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl();
const parentRef = useRef<HTMLDivElement>(null);
const node = useRef<VirtuosoHandle>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const { markChatAsRead } = useChatActions(chat.id);
const {
@ -66,6 +90,17 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const isBlocked = !!useAppSelector((state) => (state.entities[Entities.RELATIONSHIPS]?.store[chat.account.id] as Relationship)?.blocked_by);
const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
useEffect(() => {
if (!chatMessages) {
return;
}
const nextFirstItemIndex = START_INDEX - chatMessages.length;
setFirstItemIndex(nextFirstItemIndex);
}, [lastChatMessage]);
const buildCachedMessages = (): Array<ChatMessageEntity | { type: 'divider'; text: string }> => {
if (!chatMessages) {
return [];
@ -108,11 +143,23 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
};
const cachedChatMessages = buildCachedMessages();
const handleStartReached = () => {
if (hasNextPage && !isLoading && !isFetching && !isFetchingNextPage) {
const initialScrollPositionProps = useMemo(() => {
if (process.env.NODE_ENV === 'test') {
return {};
}
return {
initialTopMostItemIndex: cachedChatMessages.length - 1,
firstItemIndex: Math.max(0, firstItemIndex),
};
}, [cachedChatMessages.length, firstItemIndex]);
const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
};
return false;
}, [firstItemIndex, hasNextPage, isFetching]);
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />;
@ -190,27 +237,34 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
}
return (
<div className='flex h-full grow flex-col-reverse space-y-6 overflow-auto' style={{ scrollbarGutter: 'auto' }}>
<div className='flex grow flex-col justify-end' ref={parentRef}>
<ScrollableList
listClassName='mb-2'
loadMoreClassName='w-fit mx-auto mb-2'
<div className='flex h-full grow flex-col space-y-6'>
<div className='flex grow flex-col justify-end'>
<Virtuoso
ref={node}
alignToBottom
initialIndex={cachedChatMessages.length - 1}
hasMore={hasNextPage}
isLoading={isFetching}
showLoading={isFetching && !isFetchingNextPage}
onLoadMore={handleStartReached}
parentRef={parentRef}
>
{cachedChatMessages.map((chatMessage, index) => {
{...initialScrollPositionProps}
data={cachedChatMessages}
startReached={handleStartReached}
followOutput='auto'
itemContent={(index, chatMessage) => {
if (chatMessage.type === 'divider') {
return renderDivider(index, chatMessage.text);
return renderDivider(index, (chatMessage as any).text);
} else {
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
}
})}
</ScrollableList>
}}
components={{
List,
Scroller,
Header: () => {
if (hasNextPage || isFetchingNextPage) {
return <Spinner withText={false} />;
}
return null;
},
}}
/>
</div>
</div>
);

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -18,7 +18,6 @@ const messages = defineMessages({
const ChatPageSidebar = () => {
const intl = useIntl();
const history = useHistory();
const listRef = useRef<HTMLDivElement>(null);
const handleClickChat = (chat: Chat) => {
history.push(`/chats/${chat.id}`);
@ -33,7 +32,7 @@ const ChatPageSidebar = () => {
};
return (
<Stack space={4} className='relative h-full'>
<Stack space={4} className='h-full'>
<Stack space={4} className='px-4 pt-6'>
<HStack alignItems='center' justifyContent='between'>
<CardTitle title={intl.formatMessage(messages.title)} />
@ -54,8 +53,8 @@ const ChatPageSidebar = () => {
</HStack>
</Stack>
<Stack className='h-full grow overflow-auto' ref={listRef}>
<ChatList onClickChat={handleClickChat} parentRef={listRef} topOffset={68} />
<Stack className='h-full grow overflow-auto'>
<ChatList onClickChat={handleClickChat} />
</Stack>
</Stack>
);

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Stack from 'pl-fe/components/ui/stack';
@ -19,8 +19,6 @@ import Blankslate from './blankslate';
import type { Chat } from 'pl-api';
const ChatPane = () => {
const ref = useRef<HTMLDivElement>(null);
const { unreadChatsCount } = useStatContext();
const { screen, changeScreen, isOpen, toggleChatPane } = useChatContext();
@ -33,9 +31,9 @@ const ChatPane = () => {
const renderBody = () => {
if (Number(chats?.length) > 0 || isLoading) {
return (
<Stack space={4} className='h-full grow overflow-auto' ref={ref}>
<Stack space={4} className='h-full grow'>
{(Number(chats?.length) > 0 || isLoading) ? (
<ChatList onClickChat={handleClickChat} parentRef={ref} topOffset={64} />
<ChatList onClickChat={handleClickChat} />
) : (
<EmptyResultsBlankslate />
)}

View File

@ -89,7 +89,7 @@ const ChatSearch: React.FC<IChatSearch> = ({ isMainPage = false }) => {
};
return (
<Stack space={4} className='relative -mt-1 h-full overflow-auto'>
<Stack space={4} className='-mt-1 h-full grow'>
<div className='px-4 pt-1'>
<Input
data-testid='search'
@ -112,7 +112,7 @@ const ChatSearch: React.FC<IChatSearch> = ({ isMainPage = false }) => {
/>
</div>
<Stack className='h-full grow overflow-auto' ref={parentRef}>
<Stack className='grow' ref={parentRef}>
{renderBody()}
</Stack>
</Stack>

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import React, { useCallback, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import ScrollableList from 'pl-fe/components/scrollable-list';
import Avatar from 'pl-fe/components/ui/avatar';
import HStack from 'pl-fe/components/ui/hstack';
import Stack from 'pl-fe/components/ui/stack';
@ -54,23 +54,19 @@ const Results = ({ accountSearchResult, onSelect, parentRef }: IResults) => {
</button>
), []);
// <div className='relative grow'>
return (
<>
<ScrollableList
itemClassName='px-2'
loadMoreClassName='mx-4 mb-4'
onScroll={(startIndex, endIndex) => {
setNearTop(startIndex === 0);
setNearBottom(endIndex === accounts?.length);
}}
isLoading={isFetching}
hasMore={hasNextPage}
onLoadMore={handleLoadMore}
parentRef={parentRef}
>
{(accounts || []).map((chat) => renderAccount(chat))}
</ScrollableList>
<div className='relative grow'>
<Virtuoso
data={accounts}
itemContent={(_index, chat) => (
<div className='px-2'>
{renderAccount(chat)}
</div>
)}
endReached={handleLoadMore}
atTopStateChange={(atTop) => setNearTop(atTop)}
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
/>
<div
className={clsx('pointer-events-none absolute inset-x-0 top-[58px] flex justify-center rounded-t-lg bg-gradient-to-b from-white to-transparent pb-12 pt-8 transition-opacity duration-500 black:from-black dark:from-gray-900', {
@ -84,7 +80,7 @@ const Results = ({ accountSearchResult, onSelect, parentRef }: IResults) => {
'opacity-100': !isNearBottom,
})}
/>
</>
</div>
);
};

View File

@ -60,6 +60,7 @@ const ManagePendingParticipants: React.FC<IManagePendingParticipants> = ({ statu
return accounts ? (
<Stack space={3}>
<ScrollableList
scrollKey={`eventPendingParticipants:${statusId}`}
emptyMessage={<FormattedMessage id='empty_column.event_participant_requests' defaultMessage='There are no pending event participation requests.' />}
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}

View File

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import React from 'react';
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { expandConversations } from 'pl-fe/actions/conversations';
@ -9,8 +9,11 @@ import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import Conversation from './conversation';
import type { VirtuosoHandle } from 'react-virtuoso';
const ConversationsList: React.FC = () => {
const dispatch = useAppDispatch();
const ref = useRef<VirtuosoHandle>(null);
const conversations = useAppSelector((state) => state.conversations.items);
const isLoading = useAppSelector((state) => state.conversations.isLoading);
@ -33,6 +36,14 @@ const ConversationsList: React.FC = () => {
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
ref.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
const handleLoadOlder = debounce(() => {
@ -41,6 +52,8 @@ const ConversationsList: React.FC = () => {
return (
<ScrollableList
scrollKey='direct'
ref={ref}
hasMore={hasMore}
onLoadMore={handleLoadOlder}
id='direct-list'

View File

@ -43,6 +43,7 @@ const DomainBlocks: React.FC = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='domainBlocks'
onLoadMore={() => handleLoadMore(dispatch)}
hasMore={hasMore}
emptyMessage={emptyMessage}

View File

@ -28,6 +28,7 @@ const DraftStatuses = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='draftStatuses'
emptyMessage={emptyMessage}
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
>

View File

@ -18,6 +18,7 @@ import ThreadStatus from '../status/components/thread-status';
import { ComposeForm } from '../ui/util/async-components';
import type { MediaAttachment } from 'pl-api';
import type { VirtuosoHandle } from 'react-virtuoso';
type RouteParams = { statusId: string };
@ -42,6 +43,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = ({ params: { statusId: statu
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
const node = useRef<HTMLDivElement>(null);
const scroller = useRef<VirtuosoHandle>(null);
const fetchData = () => dispatch(fetchStatusWithContext(statusId, intl));
@ -72,6 +74,14 @@ const EventDiscussion: React.FC<IEventDiscussion> = ({ params: { statusId: statu
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
scroller.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
const renderTombstone = (id: string) => (
@ -142,8 +152,10 @@ const EventDiscussion: React.FC<IEventDiscussion> = ({ params: { statusId: statu
</div>}
<div ref={node} className='thread p-0 shadow-none sm:p-2'>
<ScrollableList
scrollKey={`eventDiscussion:${status.id}`}
id='thread'
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
initialTopMostItemIndex={0}
emptyMessage={<FormattedMessage id='event.discussion.empty' defaultMessage='No one has commented this event yet. When someone does, they will appear here.' />}
>
{children}

View File

@ -69,7 +69,11 @@ const Filters = () => {
</Button>
</HStack>
<ScrollableList emptyMessage={emptyMessage} itemClassName='pb-4 last:pb-0'>
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
itemClassName='pb-4 last:pb-0'
>
{filters.map((filter) => (
<div key={filter.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
<Stack space={2}>

View File

@ -30,7 +30,11 @@ const FollowRecommendations: React.FC = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<Stack space={4}>
<ScrollableList isLoading={isFetching} itemClassName='pb-4'>
<ScrollableList
scrollKey='followRecommendations'
isLoading={isFetching}
itemClassName='pb-4'
>
{suggestions.map((suggestion) => (
<AccountContainer
key={suggestion.account_id}

View File

@ -20,6 +20,7 @@ const OutgoingFollowRequests: React.FC = () => {
const body = accountIds ? (
<ScrollableList
scrollKey='outgoingFollowRequests'
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}

View File

@ -20,6 +20,7 @@ const FollowRequests: React.FC = () => {
const body = accountIds ? (
<ScrollableList
scrollKey='followRequests'
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}

View File

@ -21,6 +21,7 @@ const FollowedTags = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='followedTags'
emptyMessage={emptyMessage}
isLoading={isLoading}
hasMore={hasNextPage}

View File

@ -55,6 +55,7 @@ const Followers: React.FC<IFollowers> = ({ params }) => {
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='followers'
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}

View File

@ -55,6 +55,7 @@ const Following: React.FC<IFollowing> = ({ params }) => {
return (
<Column label={intl.formatMessage(messages.heading)} transparent>
<ScrollableList
scrollKey='following'
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}

View File

@ -83,7 +83,11 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
return (
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${group.id}/manage`}>
<ScrollableList emptyMessage={emptyMessage} emptyMessageCard={false}>
<ScrollableList
scrollKey={`groupBlockedMembers:${groupId}`}
emptyMessage={emptyMessage}
emptyMessageCard={false}
>
{accountIds.map((accountId) =>
<BlockedMember key={accountId} accountId={accountId} groupId={groupId} />,
)}

View File

@ -36,6 +36,7 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
return (
<>
<ScrollableList
scrollKey={`groupMembers:${groupId}`}
hasMore={hasNextPage}
onLoadMore={fetchNextPage}
isLoading={!group || isLoading}

View File

@ -108,6 +108,7 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey={`groupMembershipRequests:${groupId}`}
emptyMessage={<FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />}
>
{accounts.map((account) => (

View File

@ -68,6 +68,7 @@ const Groups: React.FC = () => {
)}
<ScrollableList
scrollKey='groups'
emptyMessage={renderBlankslate()}
emptyMessageCard={false}
itemClassName='pb-4 last:pb-0'

View File

@ -242,6 +242,7 @@ const InteractionRequests = () => {
<Column label={intl.formatMessage(messages.title)} withHeader={false}>
<PullToRefresh onRefresh={() => refetch()}>
<ScrollableList
scrollKey='interactionRequests'
isLoading={isFetching}
showLoading={isLoading}
hasMore={hasNextPage}

View File

@ -48,7 +48,7 @@ const LandingTimeline = () => {
{timelineEnabled ? (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
listClassName='black:p-0 black:sm:p-4 black:sm:pt-0'
className='black:p-0 black:sm:p-4 black:sm:pt-0'
loadMoreClassName='black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={timelineId}

View File

@ -23,6 +23,7 @@ import FilterBar from './components/filter-bar';
import Notification from './components/notification';
import type { RootState } from 'pl-fe/store';
import type { VirtuosoHandle } from 'react-virtuoso';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -64,6 +65,7 @@ const Notifications = () => {
// const isUnread = useAppSelector(state => state.notifications.unread > 0);
const hasMore = useAppSelector(state => state.notifications.hasMore);
const node = useRef<VirtuosoHandle>(null);
const scrollableContentRef = useRef<Array<JSX.Element> | null>(null);
// const handleLoadGap = (maxId) => {
@ -80,8 +82,12 @@ const Notifications = () => {
dispatch(expandNotifications({ maxId: minId }));
}, 300, { leading: true }), [displayedNotifications]);
const handleScroll = useCallback(debounce((startIndex?: number) => {
dispatch(scrollTopNotifications(startIndex === 0));
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopNotifications(true));
}, 100), []);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopNotifications(false));
}, 100), []);
const handleMoveUp = (id: string) => {
@ -99,6 +105,14 @@ const Notifications = () => {
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
const handleDequeueNotifications = useCallback(() => {
@ -114,6 +128,7 @@ const Notifications = () => {
return () => {
handleLoadOlder.cancel?.();
handleScrollToTop.cancel();
handleScroll.cancel?.();
dispatch(scrollTopNotifications(false));
};
@ -153,6 +168,8 @@ const Notifications = () => {
const scrollContainer = (
<ScrollableList
ref={node}
scrollKey='notifications'
isLoading={isLoading}
showLoading={isLoading && displayedNotifications.length === 0}
hasMore={hasMore}
@ -160,6 +177,7 @@ const Notifications = () => {
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
listClassName={clsx('divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800', {
'animate-pulse': displayedNotifications.length === 0,

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { BigCard } from 'pl-fe/components/big-card';
@ -10,7 +10,6 @@ import AccountContainer from 'pl-fe/containers/account-container';
import { useOnboardingSuggestions } from 'pl-fe/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const parentRef = useRef<HTMLDivElement>(null);
const { data, isFetching } = useOnboardingSuggestions();
const renderSuggestions = () => {
@ -19,11 +18,12 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
}
return (
<div className='flex flex-col sm:pb-10 sm:pt-4' ref={parentRef}>
<div className='flex flex-col sm:pb-10 sm:pt-4'>
<ScrollableList
scrollKey='suggestedAccounts'
isLoading={isFetching}
style={{ height: 320 }}
parentRef={parentRef}
useWindowScroll={false}
>
{data.map((suggestion) => (
<div key={suggestion.account.id} className='py-2'>

View File

@ -2,6 +2,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import PullToRefresh from 'pl-fe/components/pull-to-refresh';
import StatusList from 'pl-fe/components/status-list';
import Column from 'pl-fe/components/ui/column';
import { useIsMobile } from 'pl-fe/hooks/use-is-mobile';
@ -18,23 +19,33 @@ const Quotes: React.FC = () => {
const theme = useTheme();
const isMobile = useIsMobile();
const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId);
const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage, refetch } = useStatusQuotes(statusId);
const handleRefresh = async () => {
await refetch();
};
const handleLoadMore = () => {
fetchNextPage({ cancelRefetch: false });
};
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;
return (
<Column label={intl.formatMessage(messages.heading)} transparent={!isMobile}>
<StatusList
className='black:p-0 black:sm:p-4 black:sm:pt-0'
loadMoreClassName='black:sm:mx-4'
statusIds={statusIds}
scrollKey={`quotes:${statusId}`}
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
emptyMessage={emptyMessage}
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
/>
<PullToRefresh onRefresh={handleRefresh}>
<StatusList
className='black:p-0 black:sm:p-4 black:sm:pt-0'
loadMoreClassName='black:sm:mx-4'
statusIds={statusIds}
scrollKey={`quotes:${statusId}`}
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
/>
</PullToRefresh>
</Column>
);
};

View File

@ -35,6 +35,7 @@ const ScheduledStatuses = () => {
return (
<Column label={intl.formatMessage(messages.heading)}>
<ScrollableList
scrollKey='scheduledStatuses'
hasMore={hasMore}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(dispatch)}

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { useSearchParams } from 'react-router-dom-v5-compat';
@ -23,6 +23,8 @@ import { useSuggestedAccounts } from 'pl-fe/queries/trends/use-suggested-account
import { useTrendingLinks } from 'pl-fe/queries/trends/use-trending-links';
import { useTrendingStatuses } from 'pl-fe/queries/trends/use-trending-statuses';
import type { VirtuosoHandle } from 'react-virtuoso';
type SearchFilter = 'accounts' | 'hashtags' | 'statuses' | 'links';
const messages = defineMessages({
@ -33,6 +35,8 @@ const messages = defineMessages({
});
const SearchResults = () => {
const node = useRef<VirtuosoHandle>(null);
const intl = useIntl();
const features = useFeatures();
@ -126,6 +130,14 @@ const SearchResults = () => {
const element = document.querySelector<HTMLDivElement>(selector);
if (element) element.focus();
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
let searchResults: Array<JSX.Element> | undefined;
@ -237,6 +249,8 @@ const SearchResults = () => {
{noResultsMessage || (
<ScrollableList
scrollKey={`searchResults:${selectedFilter}:${value}`}
ref={node}
id='search-results'
key={selectedFilter}
isLoading={submitted && isLoading}

View File

@ -25,10 +25,10 @@ import { textForScreenReader } from 'pl-fe/utils/status';
import DetailedStatus from './detailed-status';
import ThreadStatus from './thread-status';
import type { Virtualizer } from '@tanstack/react-virtual';
import type { Account } from 'pl-fe/normalizers/account';
import type { Status } from 'pl-fe/normalizers/status';
import type { SelectedStatus } from 'pl-fe/selectors';
import type { VirtuosoHandle } from 'react-virtuoso';
const makeGetAncestorsIds = () => createSelector([
(_: RootState, statusId: string | undefined) => statusId,
@ -126,7 +126,7 @@ const Thread: React.FC<IThread> = ({
const node = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLDivElement>(null);
const virtualizer = useRef<Virtualizer<any, any>>(null);
const scroller = useRef<VirtuosoHandle>(null);
const handleHotkeyReact = () => {
if (statusRef.current) {
@ -247,10 +247,13 @@ const Thread: React.FC<IThread> = ({
if (element) element.focus();
if (!element) {
virtualizer.current?.scrollToIndex(index, { behavior: 'smooth' });
setTimeout(() => node.current?.querySelector<HTMLDivElement>(selector)?.focus(), 0);
}
scroller.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
if (!element) node.current?.querySelector<HTMLDivElement>(selector)?.focus();
},
});
};
const renderTombstone = (id: string) => (
@ -299,7 +302,20 @@ const Thread: React.FC<IThread> = ({
// Scroll focused status into view when thread updates.
useEffect(() => {
virtualizer.current?.scrollToIndex(ancestorsIds.length);
scroller.current?.scrollToIndex({
index: ancestorsIds.length,
offset: -146,
});
// TODO: Actually fix this
setTimeout(() => {
scroller.current?.scrollToIndex({
index: ancestorsIds.length,
offset: -146,
});
setTimeout(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus(), 0);
}, 0);
}, [status.id, ancestorsIds.length]);
const handleOpenCompareHistoryModal = useCallback((status: Pick<Status, 'id'>) => {
@ -362,10 +378,10 @@ const Thread: React.FC<IThread> = ({
</div>
);
const renderedAncestors = useMemo(() => [isModal ? <div key='padding' className='h-4' /> : null, ...renderChildren(ancestorsIds)], [ancestorsIds]);
const renderedAncestors = useMemo(() => [...(isModal ? [<div key='padding' className='h-4' />] : []), ...renderChildren(ancestorsIds)], [ancestorsIds]);
const renderedDescendants = useMemo(() => renderChildren(descendantsIds), [descendantsIds]);
const children: (JSX.Element | null)[] = [...renderedAncestors, focusedStatus, ...renderedDescendants];
const children: (JSX.Element)[] = [...renderedAncestors, focusedStatus, ...renderedDescendants];
return (
<Stack
@ -392,18 +408,20 @@ const Thread: React.FC<IThread> = ({
}
>
<ScrollableList
id='thread'
key={status.id}
ref={virtualizer}
scrollKey={`thread:${status.id}`}
id='thread'
ref={scroller}
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
initialIndex={initialIndex}
initialTopMostItemIndex={initialIndex}
itemClassName={itemClassName}
listClassName={
clsx({
'h-full': isModal,
})
}
{...(isModal ? { parentRef: node } : undefined)}
useWindowScroll={!isModal}
customScrollParent={node.current || undefined}
>
{children}
</ScrollableList>

View File

@ -30,7 +30,7 @@ const BirthdaysModal = ({ onClose }: BaseModalProps) => {
emptyMessage={emptyMessage}
listClassName='max-w-full'
itemClassName='pb-3'
estimatedSize={42}
useWindowScroll={false}
>
{accountIds.map(id =>
<Account key={id} accountId={id} />,

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -14,8 +14,6 @@ interface DislikesModalProps {
}
const DislikesModal: React.FC<BaseModalProps & DislikesModalProps> = ({ onClose, statusId }) => {
const modalRef = useRef<HTMLDivElement>(null);
const { data: accountIds, isLoading, hasNextPage, fetchNextPage } = useStatusDislikes(statusId);
const onClickClose = () => {
@ -38,8 +36,7 @@ const DislikesModal: React.FC<BaseModalProps & DislikesModalProps> = ({ onClose,
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
@ -52,7 +49,6 @@ const DislikesModal: React.FC<BaseModalProps & DislikesModalProps> = ({ onClose,
<Modal
title={<FormattedMessage id='column.dislikes' defaultMessage='Dislikes' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -32,10 +32,10 @@ const EventParticipantsModal: React.FC<BaseModalProps & EventParticipantsModalPr
emptyMessage={emptyMessage}
listClassName='max-w-full'
itemClassName='pb-3'
estimatedSize={42}
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
useWindowScroll={false}
>
{accountIds.map(id => <AccountContainer key={id} id={id} />)}
</ScrollableList>

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -19,7 +19,6 @@ interface FamiliarFollowersModalProps {
}
const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalProps> = ({ accountId, onClose }) => {
const modalRef = useRef<HTMLDivElement>(null);
const account = useAppSelector(state => getAccount(state, accountId));
const { data: familiarFollowerIds } = useFamiliarFollowers(accountId);
@ -45,8 +44,7 @@ const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalPr
emptyMessage={emptyMessage}
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{familiarFollowerIds.map(id =>
<AccountContainer key={id} id={id} />,
@ -65,7 +63,6 @@ const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalPr
/>
}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -14,8 +14,6 @@ interface FavouritesModalProps {
}
const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onClose, statusId }) => {
const modalRef = useRef<HTMLDivElement>(null);
const { data: accountIds, isLoading, hasNextPage, fetchNextPage } = useStatusFavourites(statusId);
const onClickClose = () => {
@ -38,8 +36,7 @@ const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onCl
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
@ -52,7 +49,6 @@ const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onCl
<Modal
title={<FormattedMessage id='column.favourites' defaultMessage='Likes' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { fetchStatusWithContext } from 'pl-fe/actions/statuses';
@ -17,7 +17,6 @@ interface MentionsModalProps {
}
const MentionsModal: React.FC<BaseModalProps & MentionsModalProps> = ({ onClose, statusId }) => {
const modalRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const intl = useIntl();
const getStatus = useCallback(makeGetStatus(), []);
@ -46,8 +45,7 @@ const MentionsModal: React.FC<BaseModalProps & MentionsModalProps> = ({ onClose,
<ScrollableList
listClassName='max-w-full'
itemClassName='pb-3'
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,
@ -60,7 +58,6 @@ const MentionsModal: React.FC<BaseModalProps & MentionsModalProps> = ({ onClose,
<Modal
title={<FormattedMessage id='column.mentions' defaultMessage='Mentions' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { useMemo, useRef, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -29,7 +29,6 @@ interface ReactionsModalProps {
}
const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClose, statusId, reaction: initialReaction }) => {
const modalRef = useRef<HTMLDivElement>(null);
const intl = useIntl();
const [reaction, setReaction] = useState(initialReaction);
@ -91,8 +90,7 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{accounts.map((account) =>
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,
@ -105,7 +103,6 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
<Modal
title={<FormattedMessage id='column.reactions' defaultMessage='Reactions' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -14,8 +14,6 @@ interface ReblogsModalProps {
}
const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, statusId }) => {
const modalRef = useRef<HTMLDivElement>(null);
const { data: accountIds, isLoading, hasNextPage, fetchNextPage } = useStatusReblogs(statusId);
const onClickClose = () => {
@ -38,8 +36,7 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => fetchNextPage({ cancelRefetch: false })}
estimatedSize={42}
parentRef={modalRef}
useWindowScroll={false}
>
{accountIds.map((id) =>
<AccountContainer key={id} id={id} />,
@ -52,7 +49,6 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
<Modal
title={<FormattedMessage id='column.reblogs' defaultMessage='Reposts' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View File

@ -8212,6 +8212,11 @@ react-swipeable-views@^0.14.0:
react-swipeable-views-utils "^0.14.0"
warning "^4.0.1"
react-virtuoso@^4.12.5:
version "4.12.5"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.5.tgz#cf92efc2527e56d6df1d4d63c6e4dd3fac5a4030"
integrity sha512-YeCbRRsC9CLf0buD0Rct7WsDbzf+yBU1wGbo05/XjbcN2nJuhgh040m3y3+6HVogTZxEqVm45ac9Fpae4/MxRQ==
react@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"