pl-fe: Replace virtuoso with tanstack virtual for scrollable list
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
07085b431e
commit
076b16d751
43 changed files with 145 additions and 306 deletions
|
@ -76,6 +76,7 @@
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"@types/escape-html": "^1.0.1",
|
"@types/escape-html": "^1.0.1",
|
||||||
"@types/http-link-header": "^1.0.3",
|
"@types/http-link-header": "^1.0.3",
|
||||||
"@types/leaflet": "^1.8.0",
|
"@types/leaflet": "^1.8.0",
|
||||||
|
|
|
@ -1,43 +1,14 @@
|
||||||
import debounce from 'lodash/debounce';
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
|
import { useVirtualizer, useWindowVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||||
import { useHistory } from 'react-router-dom';
|
import clsx from 'clsx';
|
||||||
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useSettings } from 'pl-fe/hooks';
|
import { useSettings } from 'pl-fe/hooks';
|
||||||
|
|
||||||
import LoadMore from './load-more';
|
import LoadMore from './load-more';
|
||||||
import { Card, Spinner } from './ui';
|
import { Card, Spinner } from './ui';
|
||||||
|
|
||||||
/** Custom Viruoso component context. */
|
interface IScrollableList {
|
||||||
type Context = {
|
|
||||||
itemClassName?: string;
|
|
||||||
listClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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. */
|
/** Pagination callback when the end of the list is reached. */
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
/** Whether the data is currently being fetched. */
|
/** Whether the data is currently being fetched. */
|
||||||
|
@ -64,12 +35,7 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
||||||
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
|
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
|
||||||
/** Number of placeholders to render while loading. */
|
/** Number of placeholders to render while loading. */
|
||||||
placeholderCount?: number;
|
placeholderCount?: number;
|
||||||
/**
|
/** Extra class names on the parent element. */
|
||||||
* Pull to refresh callback.
|
|
||||||
* @deprecated Put a PTR around the component instead.
|
|
||||||
*/
|
|
||||||
onRefresh?: () => Promise<any>;
|
|
||||||
/** Extra class names on the Virtuoso element. */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Extra class names on the list element. */
|
/** Extra class names on the list element. */
|
||||||
listClassName?: string;
|
listClassName?: string;
|
||||||
|
@ -77,17 +43,19 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
||||||
itemClassName?: string;
|
itemClassName?: string;
|
||||||
/** Extra class names on the LoadMore element */
|
/** Extra class names on the LoadMore element */
|
||||||
loadMoreClassName?: string;
|
loadMoreClassName?: string;
|
||||||
/** `id` attribute on the Virtuoso element. */
|
/** `id` attribute on the parent element. */
|
||||||
id?: string;
|
id?: string;
|
||||||
/** CSS styles on the Virtuoso element. */
|
/** CSS styles on the parent element. */
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Whether to use the window to scroll the content instead of Virtuoso's container. */
|
/** Whether to use the window to scroll the content instead of the container. */
|
||||||
useWindowScroll?: boolean;
|
useWindowScroll?: boolean;
|
||||||
|
/** Initial item index to scroll to. */
|
||||||
|
initialIndex?: number;
|
||||||
|
/** Estimated size for items */
|
||||||
|
estimatedSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy ScrollableList with Virtuoso for backwards-compatibility. */
|
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(({
|
||||||
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|
||||||
scrollKey,
|
|
||||||
prepend = null,
|
prepend = null,
|
||||||
alwaysPrepend,
|
alwaysPrepend,
|
||||||
children,
|
children,
|
||||||
|
@ -95,7 +63,6 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
emptyMessageCard = true,
|
emptyMessageCard = true,
|
||||||
showLoading,
|
showLoading,
|
||||||
onRefresh,
|
|
||||||
onScroll,
|
onScroll,
|
||||||
onScrollToTop,
|
onScrollToTop,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
|
@ -107,58 +74,70 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
hasMore,
|
hasMore,
|
||||||
placeholderComponent: Placeholder,
|
placeholderComponent: Placeholder,
|
||||||
placeholderCount = 0,
|
placeholderCount = 0,
|
||||||
initialTopMostItemIndex = 0,
|
initialIndex = 0,
|
||||||
style = {},
|
style = {},
|
||||||
useWindowScroll = true,
|
useWindowScroll = true,
|
||||||
|
estimatedSize = 300,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const history = useHistory();
|
|
||||||
const { autoloadMore } = useSettings();
|
const { autoloadMore } = useSettings();
|
||||||
|
|
||||||
// Preserve scroll position
|
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||||
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. */
|
/** Normalized children. */
|
||||||
const elements = Array.from(children || []);
|
const elements = Array.from(children || []);
|
||||||
|
|
||||||
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
|
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 data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
|
||||||
|
|
||||||
// Add a placeholder at the bottom for loading
|
const virtualizer = useWindowScroll ? useWindowVirtualizer({
|
||||||
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
|
count: data.length + (hasMore ? 1 : 0),
|
||||||
if (hasMore && (autoloadMore || isLoading) && Placeholder) {
|
overscan: 3,
|
||||||
data.push(<Placeholder />);
|
// scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||||
} else if (hasMore && (autoloadMore || isLoading)) {
|
estimateSize: () => estimatedSize,
|
||||||
data.push(<Spinner />);
|
}) : useVirtualizer({
|
||||||
}
|
count: data.length + (hasMore ? 1 : 0),
|
||||||
|
overscan: 3,
|
||||||
const handleScroll = useCallback(debounce(() => {
|
// scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||||
// HACK: Virtuoso has no better way to get this...
|
estimateSize: () => estimatedSize,
|
||||||
const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`);
|
getScrollElement: () => parentRef.current,
|
||||||
if (node) {
|
});
|
||||||
topOffset.current = node.getBoundingClientRect().top * -1;
|
|
||||||
} else {
|
|
||||||
topOffset.current = 0;
|
|
||||||
}
|
|
||||||
}, 150, { trailing: true }), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('scroll', handleScroll);
|
if (typeof ref === 'function') ref(virtualizer); else if (ref !== null) ref.current = virtualizer;
|
||||||
sessionStorage.removeItem(scrollDataKey);
|
}, [virtualizer]);
|
||||||
|
|
||||||
return () => {
|
const range = virtualizer.calculateRange();
|
||||||
if (scrollKey) {
|
|
||||||
const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
|
useEffect(() => {
|
||||||
sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
|
if (showLoading) return;
|
||||||
}
|
|
||||||
document.removeEventListener('scroll', handleScroll);
|
if (typeof initialIndex === 'number') virtualizer.scrollToIndex(initialIndex);
|
||||||
};
|
}, [showLoading, initialIndex]);
|
||||||
}, []);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (range?.startIndex === 0) {
|
||||||
|
onScrollToTop?.();
|
||||||
|
} else onScroll?.();
|
||||||
|
}, [range?.startIndex === 0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onLoadMore && range?.endIndex === data.length && !showLoading && autoloadMore && hasMore) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
}, [range?.endIndex]);
|
||||||
|
|
||||||
|
const loadMore = useMemo(() => {
|
||||||
|
if (autoloadMore || !hasMore || !onLoadMore) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
const button = <LoadMore className='mt-2' visible={!isLoading} onClick={onLoadMore} />;
|
||||||
|
|
||||||
|
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
}, [autoloadMore, hasMore, isLoading]);
|
||||||
|
|
||||||
/* Render an empty state instead of the scrollable list. */
|
/* Render an empty state instead of the scrollable list. */
|
||||||
const renderEmpty = (): JSX.Element => (
|
const renderEmpty = (): JSX.Element => (
|
||||||
|
@ -179,94 +158,52 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Render a single item. */
|
const renderItem = (index: number): JSX.Element => {
|
||||||
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
|
const PlaceholderComponent = Placeholder || Spinner;
|
||||||
if (showPlaceholder) {
|
if (index === data.length) return (isLoading) ? <PlaceholderComponent /> : loadMore || <div className='h-4' />;
|
||||||
return <Placeholder />;
|
if (showPlaceholder) return <PlaceholderComponent />;
|
||||||
} else {
|
return data[index];
|
||||||
return element;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndReached = () => {
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
if (autoloadMore && hasMore && onLoadMore) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMore = () => {
|
|
||||||
if (autoloadMore || !hasMore || !onLoadMore) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
const button = <LoadMore visible={!isLoading} onClick={onLoadMore} />;
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Virtuoso
|
<div
|
||||||
ref={ref}
|
ref={parentRef}
|
||||||
id={id}
|
id={id}
|
||||||
useWindowScroll={useWindowScroll}
|
className={clsx(className, 'w-full')}
|
||||||
data={data}
|
|
||||||
totalCount={data.length}
|
|
||||||
startReached={onScrollToTop}
|
|
||||||
endReached={handleEndReached}
|
|
||||||
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
|
||||||
itemContent={renderItem}
|
|
||||||
initialTopMostItemIndex={initialIndex}
|
|
||||||
rangeChanged={handleRangeChange}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
style={style}
|
||||||
context={{
|
>
|
||||||
listClassName,
|
<div
|
||||||
itemClassName,
|
className={listClassName}
|
||||||
}}
|
style={{
|
||||||
components={{
|
height: virtualizer.getTotalSize(),
|
||||||
Header: () => <>{prepend}</>,
|
width: '100%',
|
||||||
ScrollSeekPlaceholder: Placeholder as any,
|
position: 'relative',
|
||||||
EmptyPlaceholder: () => renderEmpty(),
|
}}
|
||||||
List,
|
>
|
||||||
Item,
|
{!showLoading && data.length ? (
|
||||||
Footer: loadMore,
|
<>
|
||||||
}}
|
{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}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(item.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : renderEmpty()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import React, { useRef, useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import LoadGap from 'pl-fe/components/load-gap';
|
import LoadGap from 'pl-fe/components/load-gap';
|
||||||
|
@ -15,7 +15,6 @@ import { Stack, Text } from './ui';
|
||||||
|
|
||||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import type { IScrollableList } from 'pl-fe/components/scrollable-list';
|
import type { IScrollableList } from 'pl-fe/components/scrollable-list';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||||
/** Unique key to preserve the scroll position when navigating back. */
|
/** Unique key to preserve the scroll position when navigating back. */
|
||||||
|
@ -62,7 +61,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
const plFeConfig = usePlFeConfig();
|
const plFeConfig = usePlFeConfig();
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
|
||||||
|
|
||||||
const getFeaturedStatusCount = () => featuredStatusIds?.size || 0;
|
const getFeaturedStatusCount = () => featuredStatusIds?.size || 0;
|
||||||
|
|
||||||
|
@ -96,14 +94,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = document.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
node.current?.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLoadGap = (index: number) => {
|
const renderLoadGap = (index: number) => {
|
||||||
|
@ -179,7 +169,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
return statusIds.toList().reduce((acc, statusId, index) => {
|
return statusIds.toList().reduce((acc, statusId, index) => {
|
||||||
if (statusId === null) {
|
if (statusId === null) {
|
||||||
const gap = renderLoadGap(index);
|
const gap = renderLoadGap(index);
|
||||||
// one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793
|
|
||||||
if (gap) {
|
if (gap) {
|
||||||
acc.push(gap);
|
acc.push(gap);
|
||||||
}
|
}
|
||||||
|
@ -234,10 +223,10 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
onLoadMore={handleLoadOlder}
|
onLoadMore={handleLoadOlder}
|
||||||
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
||||||
placeholderCount={20}
|
placeholderCount={20}
|
||||||
ref={node}
|
className={className}
|
||||||
listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
||||||
'divide-none': divideType !== 'border',
|
'divide-none': divideType !== 'border',
|
||||||
}, className)}
|
})}
|
||||||
itemClassName={clsx({
|
itemClassName={clsx({
|
||||||
'pb-3': divideType !== 'border',
|
'pb-3': divideType !== 'border',
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -109,7 +109,6 @@ const Announcements: React.FC = () => {
|
||||||
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
|
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
|
||||||
</Button>
|
</Button>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='announcements'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
|
@ -128,7 +128,6 @@ const Domains: React.FC = () => {
|
||||||
</Button>
|
</Button>
|
||||||
{domains && (
|
{domains && (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='domains'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
isLoading={isFetching}
|
isLoading={isFetching}
|
||||||
|
|
|
@ -33,7 +33,6 @@ const ModerationLog = () => {
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={showLoading}
|
showLoading={showLoading}
|
||||||
scrollKey='moderation-log'
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
|
|
|
@ -120,7 +120,6 @@ const Relays: React.FC = () => {
|
||||||
|
|
||||||
{relays && (
|
{relays && (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='relays'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
isLoading={isFetching}
|
isLoading={isFetching}
|
||||||
|
|
|
@ -93,7 +93,6 @@ const Rules: React.FC = () => {
|
||||||
<FormattedMessage id='admin.rules.action' defaultMessage='Create rule' />
|
<FormattedMessage id='admin.rules.action' defaultMessage='Create rule' />
|
||||||
</Button>
|
</Button>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='rules'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
|
@ -34,7 +34,6 @@ const AwaitingApproval: React.FC = () => {
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={showLoading}
|
showLoading={showLoading}
|
||||||
scrollKey='awaiting-approval'
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||||
>
|
>
|
||||||
|
|
|
@ -33,7 +33,6 @@ const Reports: React.FC = () => {
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={showLoading}
|
showLoading={showLoading}
|
||||||
scrollKey='admin-reports'
|
|
||||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||||
>
|
>
|
||||||
|
|
|
@ -49,7 +49,6 @@ const UserIndex: React.FC = () => {
|
||||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||||
/>
|
/>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='user-index'
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={showLoading}
|
showLoading={showLoading}
|
||||||
|
|
|
@ -67,10 +67,7 @@ const Aliases = () => {
|
||||||
<CardTitle title={intl.formatMessage(messages.subheading_aliases)} />
|
<CardTitle title={intl.formatMessage(messages.subheading_aliases)} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<ScrollableList
|
<ScrollableList emptyMessage={emptyMessage}>
|
||||||
scrollKey='aliases'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
>
|
|
||||||
{aliases.map((alias, i) => (
|
{aliases.map((alias, i) => (
|
||||||
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
<HStack alignItems='center' justifyContent='between' space={1} key={i} className='p-2'>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -33,7 +33,6 @@ const Blocks: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='blocks'
|
|
||||||
onLoadMore={fetchNextPage}
|
onLoadMore={fetchNextPage}
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { expandConversations } from 'pl-fe/actions/conversations';
|
import { expandConversations } from 'pl-fe/actions/conversations';
|
||||||
|
@ -8,11 +8,8 @@ import { useAppDispatch, useAppSelector } from 'pl-fe/hooks';
|
||||||
|
|
||||||
import Conversation from './conversation';
|
import Conversation from './conversation';
|
||||||
|
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
const ConversationsList: React.FC = () => {
|
const ConversationsList: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const ref = useRef<VirtuosoHandle>(null);
|
|
||||||
|
|
||||||
const conversations = useAppSelector((state) => state.conversations.items);
|
const conversations = useAppSelector((state) => state.conversations.items);
|
||||||
const isLoading = useAppSelector((state) => state.conversations.isLoading);
|
const isLoading = useAppSelector((state) => state.conversations.isLoading);
|
||||||
|
@ -35,14 +32,6 @@ const ConversationsList: React.FC = () => {
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = document.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
ref.current?.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadOlder = debounce(() => {
|
const handleLoadOlder = debounce(() => {
|
||||||
|
@ -54,8 +43,6 @@ const ConversationsList: React.FC = () => {
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
onLoadMore={handleLoadOlder}
|
onLoadMore={handleLoadOlder}
|
||||||
id='direct-list'
|
id='direct-list'
|
||||||
scrollKey='direct'
|
|
||||||
ref={ref}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && conversations.size === 0}
|
showLoading={isLoading && conversations.size === 0}
|
||||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||||
|
|
|
@ -41,7 +41,6 @@ const DomainBlocks: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='domain_blocks'
|
|
||||||
onLoadMore={() => handleLoadMore(dispatch)}
|
onLoadMore={() => handleLoadMore(dispatch)}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
|
|
@ -27,7 +27,6 @@ const DraftStatuses = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='draft_statuses'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||||
>
|
>
|
||||||
|
|
|
@ -18,7 +18,6 @@ import { getDescendantsIds } from '../status/components/thread';
|
||||||
import ThreadStatus from '../status/components/thread-status';
|
import ThreadStatus from '../status/components/thread-status';
|
||||||
|
|
||||||
import type { MediaAttachment } from 'pl-api';
|
import type { MediaAttachment } from 'pl-api';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
type RouteParams = { statusId: string };
|
type RouteParams = { statusId: string };
|
||||||
|
|
||||||
|
@ -52,7 +51,6 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||||
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
const scroller = useRef<VirtuosoHandle>(null);
|
|
||||||
|
|
||||||
const fetchData = () => {
|
const fetchData = () => {
|
||||||
const { params } = props;
|
const { params } = props;
|
||||||
|
@ -87,14 +85,6 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = document.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
scroller.current?.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTombstone = (id: string) => (
|
const renderTombstone = (id: string) => (
|
||||||
|
@ -166,9 +156,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
|
||||||
<div ref={node} className='thread p-0 shadow-none sm:p-2'>
|
<div ref={node} className='thread p-0 shadow-none sm:p-2'>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
id='thread'
|
id='thread'
|
||||||
ref={scroller}
|
|
||||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
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.' />}
|
emptyMessage={<FormattedMessage id='event.discussion.empty' defaultMessage='No one has commented this event yet. When someone does, they will appear here.' />}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -63,11 +63,7 @@ const Filters = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList emptyMessage={emptyMessage} itemClassName='pb-4 last:pb-0'>
|
||||||
scrollKey='filters'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
itemClassName='pb-4 last:pb-0'
|
|
||||||
>
|
|
||||||
{filters.map((filter) => (
|
{filters.map((filter) => (
|
||||||
<div key={filter.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
<div key={filter.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
|
|
|
@ -28,11 +28,7 @@ const FollowRecommendations: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<Stack space={4}>
|
<Stack space={4}>
|
||||||
<ScrollableList
|
<ScrollableList isLoading={isFetching} itemClassName='pb-4'>
|
||||||
isLoading={isFetching}
|
|
||||||
scrollKey='suggestions'
|
|
||||||
itemClassName='pb-4'
|
|
||||||
>
|
|
||||||
{suggestions.map((suggestion) => (
|
{suggestions.map((suggestion) => (
|
||||||
<AccountContainer
|
<AccountContainer
|
||||||
key={suggestion.account_id}
|
key={suggestion.account_id}
|
||||||
|
|
|
@ -41,7 +41,6 @@ const FollowRequests: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='follow_requests'
|
|
||||||
onLoadMore={() => handleLoadMore(dispatch)}
|
onLoadMore={() => handleLoadMore(dispatch)}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
|
|
@ -34,7 +34,6 @@ const FollowedTags = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='followed_tags'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|
|
@ -53,7 +53,6 @@ const Followers: React.FC<IFollowers> = ({ params }) => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='followers'
|
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
onLoadMore={fetchNextPage}
|
onLoadMore={fetchNextPage}
|
||||||
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
|
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
|
||||||
|
|
|
@ -53,7 +53,6 @@ const Following: React.FC<IFollowing> = ({ params }) => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='following'
|
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
onLoadMore={fetchNextPage}
|
onLoadMore={fetchNextPage}
|
||||||
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
|
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
|
||||||
|
|
|
@ -83,11 +83,7 @@ const GroupBlockedMembers: React.FC<IGroupBlockedMembers> = ({ params }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${group.id}/manage`}>
|
<Column label={intl.formatMessage(messages.heading)} backHref={`/groups/${group.id}/manage`}>
|
||||||
<ScrollableList
|
<ScrollableList emptyMessage={emptyMessage} emptyMessageCard={false}>
|
||||||
scrollKey='group_blocks'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
emptyMessageCard={false}
|
|
||||||
>
|
|
||||||
{accountIds.map((accountId) =>
|
{accountIds.map((accountId) =>
|
||||||
<BlockedMember key={accountId} accountId={accountId} groupId={groupId} />,
|
<BlockedMember key={accountId} accountId={accountId} groupId={groupId} />,
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -34,7 +34,6 @@ const GroupMembers: React.FC<IGroupMembers> = (props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='group-members'
|
|
||||||
hasMore={hasNextPage}
|
hasMore={hasNextPage}
|
||||||
onLoadMore={fetchNextPage}
|
onLoadMore={fetchNextPage}
|
||||||
isLoading={!group || isLoading}
|
isLoading={!group || isLoading}
|
||||||
|
|
|
@ -111,7 +111,6 @@ const GroupMembershipRequests: React.FC<IGroupMembershipRequests> = ({ params })
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='group_membership_requests'
|
|
||||||
emptyMessage={<FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />}
|
emptyMessage={<FormattedMessage id='empty_column.group_membership_requests' defaultMessage='There are no pending membership requests for this group.' />}
|
||||||
>
|
>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
|
|
|
@ -66,7 +66,6 @@ const Groups: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='groups'
|
|
||||||
emptyMessage={renderBlankslate()}
|
emptyMessage={renderBlankslate()}
|
||||||
emptyMessageCard={false}
|
emptyMessageCard={false}
|
||||||
itemClassName='pb-4 last:pb-0'
|
itemClassName='pb-4 last:pb-0'
|
||||||
|
|
|
@ -21,7 +21,6 @@ import FilterBar from './components/filter-bar';
|
||||||
import Notification from './components/notification';
|
import Notification from './components/notification';
|
||||||
|
|
||||||
import type { RootState } from 'pl-fe/store';
|
import type { RootState } from 'pl-fe/store';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||||
|
@ -45,7 +44,6 @@ const Notifications = () => {
|
||||||
const hasMore = useAppSelector(state => state.notifications.hasMore);
|
const hasMore = useAppSelector(state => state.notifications.hasMore);
|
||||||
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
|
const totalQueuedNotificationsCount = useAppSelector(state => state.notifications.totalQueuedNotificationsCount || 0);
|
||||||
|
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
|
||||||
const column = useRef<HTMLDivElement>(null);
|
const column = useRef<HTMLDivElement>(null);
|
||||||
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
|
const scrollableContentRef = useRef<ImmutableList<JSX.Element> | null>(null);
|
||||||
|
|
||||||
|
@ -81,14 +79,6 @@ const Notifications = () => {
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = document.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
node.current?.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDequeueNotifications = useCallback(() => {
|
const handleDequeueNotifications = useCallback(() => {
|
||||||
|
@ -138,8 +128,6 @@ const Notifications = () => {
|
||||||
|
|
||||||
const scrollContainer = (
|
const scrollContainer = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
ref={node}
|
|
||||||
scrollKey='notifications'
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
showLoading={isLoading && notifications.size === 0}
|
showLoading={isLoading && notifications.size === 0}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|
|
@ -19,7 +19,6 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
<div className='flex flex-col sm:pb-10 sm:pt-4'>
|
<div className='flex flex-col sm:pb-10 sm:pt-4'>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
isLoading={isFetching}
|
isLoading={isFetching}
|
||||||
scrollKey='suggestions'
|
|
||||||
useWindowScroll={false}
|
useWindowScroll={false}
|
||||||
style={{ height: 320 }}
|
style={{ height: 320 }}
|
||||||
>
|
>
|
||||||
|
|
|
@ -46,7 +46,6 @@ const Quotes: React.FC = () => {
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||||
onLoadMore={() => handleLoadMore(statusId, dispatch)}
|
onLoadMore={() => handleLoadMore(statusId, dispatch)}
|
||||||
onRefresh={handleRefresh}
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
|
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -34,7 +34,6 @@ const ScheduledStatuses = () => {
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='scheduled_statuses'
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||||
onLoadMore={() => handleLoadMore(dispatch)}
|
onLoadMore={() => handleLoadMore(dispatch)}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { List as ImmutableList, type OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import { List as ImmutableList, type OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { expandSearch, setFilter, setSearchAccount } from 'pl-fe/actions/search';
|
import { expandSearch, setFilter, setSearchAccount } from 'pl-fe/actions/search';
|
||||||
|
@ -19,7 +19,6 @@ import PlaceholderStatus from 'pl-fe/features/placeholder/components/placeholder
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'pl-fe/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures } from 'pl-fe/hooks';
|
||||||
|
|
||||||
import type { SearchFilter } from 'pl-fe/reducers/search';
|
import type { SearchFilter } from 'pl-fe/reducers/search';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||||
|
@ -29,8 +28,6 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const SearchResults = () => {
|
const SearchResults = () => {
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
@ -104,14 +101,6 @@ const SearchResults = () => {
|
||||||
const element = document.querySelector<HTMLDivElement>(selector);
|
const element = document.querySelector<HTMLDivElement>(selector);
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
node.current?.scrollIntoView({
|
|
||||||
index,
|
|
||||||
behavior: 'smooth',
|
|
||||||
done: () => {
|
|
||||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -237,9 +226,7 @@ const SearchResults = () => {
|
||||||
{noResultsMessage || (
|
{noResultsMessage || (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
id='search-results'
|
id='search-results'
|
||||||
ref={node}
|
|
||||||
key={selectedFilter}
|
key={selectedFilter}
|
||||||
scrollKey={`${selectedFilter}:${value}`}
|
|
||||||
isLoading={submitted && !loaded}
|
isLoading={submitted && !loaded}
|
||||||
showLoading={submitted && !loaded && (!searchResults || searchResults?.isEmpty())}
|
showLoading={submitted && !loaded && (!searchResults || searchResults?.isEmpty())}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|
|
@ -25,9 +25,9 @@ import { textForScreenReader } from 'pl-fe/utils/status';
|
||||||
import DetailedStatus from './detailed-status';
|
import DetailedStatus from './detailed-status';
|
||||||
import ThreadStatus from './thread-status';
|
import ThreadStatus from './thread-status';
|
||||||
|
|
||||||
|
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||||
import type { Account, Status } from 'pl-fe/normalizers';
|
import type { Account, Status } from 'pl-fe/normalizers';
|
||||||
import type { SelectedStatus } from 'pl-fe/selectors';
|
import type { SelectedStatus } from 'pl-fe/selectors';
|
||||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
const getAncestorsIds = createSelector([
|
const getAncestorsIds = createSelector([
|
||||||
(_: RootState, statusId: string | undefined) => statusId,
|
(_: RootState, statusId: string | undefined) => statusId,
|
||||||
|
@ -113,12 +113,12 @@ const Thread: React.FC<IThread> = ({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let initialTopMostItemIndex = ancestorsIds.size;
|
let initialIndex = ancestorsIds.size;
|
||||||
if (!useWindowScroll && initialTopMostItemIndex !== 0) initialTopMostItemIndex = ancestorsIds.size + 1;
|
if (!useWindowScroll && initialIndex !== 0) initialIndex = ancestorsIds.size + 1;
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
const statusRef = useRef<HTMLDivElement>(null);
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
const scroller = useRef<VirtuosoHandle>(null);
|
const virtualizer = useRef<Virtualizer<any, any>>(null);
|
||||||
|
|
||||||
const handleHotkeyReact = () => {
|
const handleHotkeyReact = () => {
|
||||||
if (statusRef.current) {
|
if (statusRef.current) {
|
||||||
|
@ -241,13 +241,10 @@ const Thread: React.FC<IThread> = ({
|
||||||
|
|
||||||
if (element) element.focus();
|
if (element) element.focus();
|
||||||
|
|
||||||
scroller.current?.scrollIntoView({
|
if (!element) {
|
||||||
index,
|
virtualizer.current?.scrollToIndex(index, { behavior: 'smooth' });
|
||||||
behavior: 'smooth',
|
setTimeout(() => node.current?.querySelector<HTMLDivElement>(selector)?.focus(), 0);
|
||||||
done: () => {
|
}
|
||||||
if (!element) node.current?.querySelector<HTMLDivElement>(selector)?.focus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTombstone = (id: string) => (
|
const renderTombstone = (id: string) => (
|
||||||
|
@ -296,20 +293,7 @@ const Thread: React.FC<IThread> = ({
|
||||||
|
|
||||||
// Scroll focused status into view when thread updates.
|
// Scroll focused status into view when thread updates.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scroller.current?.scrollToIndex({
|
virtualizer.current?.scrollToIndex(ancestorsIds.size);
|
||||||
index: ancestorsIds.size,
|
|
||||||
offset: -146,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Actually fix this
|
|
||||||
setTimeout(() => {
|
|
||||||
scroller.current?.scrollToIndex({
|
|
||||||
index: ancestorsIds.size,
|
|
||||||
offset: -146,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => statusRef.current?.querySelector<HTMLDivElement>('.detailed-actualStatus')?.focus(), 0);
|
|
||||||
}, 0);
|
|
||||||
}, [status.id, ancestorsIds.size]);
|
}, [status.id, ancestorsIds.size]);
|
||||||
|
|
||||||
const handleOpenCompareHistoryModal = (status: Pick<Status, 'id'>) => {
|
const handleOpenCompareHistoryModal = (status: Pick<Status, 'id'>) => {
|
||||||
|
@ -413,9 +397,9 @@ const Thread: React.FC<IThread> = ({
|
||||||
>
|
>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
id='thread'
|
id='thread'
|
||||||
ref={scroller}
|
ref={virtualizer}
|
||||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
initialIndex={initialIndex}
|
||||||
useWindowScroll={useWindowScroll}
|
useWindowScroll={useWindowScroll}
|
||||||
itemClassName={itemClassName}
|
itemClassName={itemClassName}
|
||||||
listClassName={
|
listClassName={
|
||||||
|
|
|
@ -24,10 +24,10 @@ const BirthdaysModal = ({ onClose }: BaseModalProps) => {
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='birthdays'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map(id =>
|
{accountIds.map(id =>
|
||||||
<Account key={id} accountId={id} />,
|
<Account key={id} accountId={id} />,
|
||||||
|
|
|
@ -39,10 +39,10 @@ const DislikesModal: React.FC<BaseModalProps & DislikesModalProps> = ({ onClose,
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='dislikes'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map(id =>
|
{accountIds.map(id =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -39,10 +39,10 @@ const EventParticipantsModal: React.FC<BaseModalProps & EventParticipantsModalPr
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='event_participations'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map(id =>
|
{accountIds.map(id =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -33,11 +33,11 @@ const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalPr
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='familiar_followers'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
style={{ height: 'calc(80vh - 88px)' }}
|
style={{ height: 'calc(80vh - 88px)' }}
|
||||||
useWindowScroll={false}
|
useWindowScroll={false}
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{familiarFollowerIds.map(id =>
|
{familiarFollowerIds.map(id =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -46,7 +46,6 @@ const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onCl
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='favourites'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
@ -54,6 +53,7 @@ const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onCl
|
||||||
useWindowScroll={false}
|
useWindowScroll={false}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
hasMore={!!next}
|
hasMore={!!next}
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map(id =>
|
{accountIds.map(id =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -42,9 +42,9 @@ const MentionsModal: React.FC<BaseModalProps & MentionsModalProps> = ({ onClose,
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='mentions'
|
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map(id =>
|
{accountIds.map(id =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -86,7 +86,6 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
|
||||||
body = (<>
|
body = (<>
|
||||||
{reactions.size > 0 && renderFilterBar()}
|
{reactions.size > 0 && renderFilterBar()}
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='reactions'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
className={clsx({
|
className={clsx({
|
||||||
'mt-4': reactions.size > 0,
|
'mt-4': reactions.size > 0,
|
||||||
|
@ -95,6 +94,7 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
style={{ height: 'calc(80vh - 88px)' }}
|
style={{ height: 'calc(80vh - 88px)' }}
|
||||||
useWindowScroll={false}
|
useWindowScroll={false}
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accounts.map((account) =>
|
{accounts.map((account) =>
|
||||||
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,
|
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,
|
||||||
|
|
|
@ -48,7 +48,6 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
scrollKey='reblogs'
|
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
listClassName='max-w-full'
|
listClassName='max-w-full'
|
||||||
itemClassName='pb-3'
|
itemClassName='pb-3'
|
||||||
|
@ -56,6 +55,7 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
|
||||||
useWindowScroll={false}
|
useWindowScroll={false}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
hasMore={!!next}
|
hasMore={!!next}
|
||||||
|
estimatedSize={42}
|
||||||
>
|
>
|
||||||
{accountIds.map((id) =>
|
{accountIds.map((id) =>
|
||||||
<AccountContainer key={id} id={id} />,
|
<AccountContainer key={id} id={id} />,
|
||||||
|
|
|
@ -15,6 +15,8 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
|
interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
|
||||||
|
/** Unique key to preserve the scroll position when navigating back. */
|
||||||
|
scrollKey: string;
|
||||||
/** ID of the timeline in Redux. */
|
/** ID of the timeline in Redux. */
|
||||||
timelineId: string;
|
timelineId: string;
|
||||||
/** Settings path to use instead of the timelineId. */
|
/** Settings path to use instead of the timelineId. */
|
||||||
|
|
|
@ -2346,6 +2346,18 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tanstack/query-core" "5.0.0"
|
"@tanstack/query-core" "5.0.0"
|
||||||
|
|
||||||
|
"@tanstack/react-virtual@^3.10.8":
|
||||||
|
version "3.10.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz#bf4b06f157ed298644a96ab7efc1a2b01ab36e3c"
|
||||||
|
integrity sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/virtual-core" "3.10.8"
|
||||||
|
|
||||||
|
"@tanstack/virtual-core@3.10.8":
|
||||||
|
version "3.10.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4"
|
||||||
|
integrity sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==
|
||||||
|
|
||||||
"@testing-library/dom@^9.0.0":
|
"@testing-library/dom@^9.0.0":
|
||||||
version "9.0.1"
|
version "9.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.0.1.tgz#fb9e3837fe2a662965df1536988f0863f01dbf51"
|
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.0.1.tgz#fb9e3837fe2a662965df1536988f0863f01dbf51"
|
||||||
|
|
Loading…
Reference in a new issue