pl-fe: Migrate back to react-virtuoso
Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
})}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -33,6 +33,7 @@ const AwaitingApproval: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
scrollKey='awaitingApproval'
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
|
||||
@ -32,6 +32,7 @@ const Reports: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
scrollKey='adminReports'
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
|
||||
@ -51,6 +51,7 @@ const UserIndex: React.FC = () => {
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
<ScrollableList
|
||||
scrollKey='userIndex'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -34,6 +34,7 @@ const Blocks: React.FC = () => {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='blocks'
|
||||
onLoadMore={fetchNextPage}
|
||||
hasMore={hasNextPage}
|
||||
emptyMessage={emptyMessage}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 />
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -21,6 +21,7 @@ const FollowedTags = () => {
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
scrollKey='followedTags'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasNextPage}
|
||||
|
||||
@ -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.' />}
|
||||
|
||||
@ -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." />}
|
||||
|
||||
@ -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} />,
|
||||
)}
|
||||
|
||||
@ -36,6 +36,7 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<ScrollableList
|
||||
scrollKey={`groupMembers:${groupId}`}
|
||||
hasMore={hasNextPage}
|
||||
onLoadMore={fetchNextPage}
|
||||
isLoading={!group || isLoading}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -68,6 +68,7 @@ const Groups: React.FC = () => {
|
||||
)}
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='groups'
|
||||
emptyMessage={renderBlankslate()}
|
||||
emptyMessageCard={false}
|
||||
itemClassName='pb-4 last:pb-0'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user