Files
HKSingleParty/99_references/beacon-main/src/components/scrollable-content.tsx
2025-05-28 09:55:51 +08:00

449 lines
11 KiB
TypeScript

/**
* @file Scrollable content component
*/
import {
IonContent,
IonInfiniteScroll,
IonInfiniteScrollContent,
IonItem,
IonRefresher,
IonRefresherContent,
IonSpinner,
RefresherEventDetail,
} from "@ionic/react";
import {ReactNode, useEffect, useRef, useState} from "react";
import {useMeasure} from "react-use";
import {VList, VListHandle} from "virtua";
import {useEphemeralStore} from "~/lib/stores/ephemeral";
import {KeysOfType} from "~/lib/types";
/**
* Scrollable content component props
* @param T Content item type
*/
interface ScrollableContentProps<T extends object> {
/**
* Singular content item name (e.g.: `comment`)
*/
contentItemName: string;
/**
* Content items
*/
contentItems: T[];
/**
* Set content items
*/
setContentItems: (contentItems: T[]) => void;
/**
* Content item unique identifier key
*/
contentItemIDKey: KeysOfType<T, string>;
/**
* Content item rank key
*/
contentItemRankKey: KeysOfType<T, number>;
/**
* Content item viewed event handler
* @param contentItem Content item
*/
onContentItemViewed?: (contentItem: T) => void;
/**
* Content item renderer
* @param item Content item
* @param index Content item index
* @param onLoad Content item load event handler
* @returns JSX
*/
contentItemRenderer: (
item: T,
index: number,
onLoad: () => void,
) => ReactNode;
/**
* Fetch content
* @param limit Limit
* @param cutoffRank Cutoff rank or undefined for no cutoff
* @returns Content items
*/
fetchContent: (limit: number, cutoffRank?: number) => T[] | Promise<T[]>;
/**
* Refresh event handler
*/
onRefresh?: () => void | Promise<void>;
/**
* Header slot (Inline with content items)
*/
header?: ReactNode;
/**
* Content range limit (Maximum number of content items to fetch at a time)
*/
contentRangeLimit?: number;
/**
* Prefetch time coefficient (Multiplied by the estimated time to scroll to the bottom to determine when to prefetch more content)
*/
prefetchTimeCoefficient?: number;
/**
* Maximum number of scroll metadatas to keep (Fewer metadatas means less accurate velocity calculations but less memory usage)
*/
maximumScrollMetadatas?: number;
}
/**
* Content index range (start, end)
*/
type ContentRange = [number, number];
/**
* Scroll metadata
*/
interface ScrollMetadata {
/**
* Scroll offset
*/
offset: number;
/**
* Timestamp
*/
timestamp: Date;
}
/**
* Scrollable content component
* @param props Props
* @returns JSX
*/
export const ScrollableContent = <T extends object>({
contentItemName,
contentItems,
setContentItems,
contentItemIDKey,
contentItemRankKey,
onContentItemViewed,
contentItemRenderer,
fetchContent: baseFetchContent,
onRefresh,
header,
contentRangeLimit = 9,
prefetchTimeCoefficient = 1.2,
maximumScrollMetadatas = 10,
}: ScrollableContentProps<T>) => {
// Constants
/**
* Default content index range
*/
const defaultContentRange: ContentRange = [0, contentRangeLimit];
// Hooks
const [outOfContent, setOutOfContent] = useState(false);
const [fetching, setFetching] = useState(false);
const rankCutoff = useRef<number | undefined>(undefined);
const visibleContentRange = useRef<ContentRange>([...defaultContentRange]);
const fetchLatency = useRef(50);
const virtualScroller = useRef<VListHandle>(null);
const previousScrollMetadatas = useRef<ScrollMetadata[]>([]);
const loadedContentItems = useRef(new Set<string>());
const viewedContentItems = useRef(new Set<string>());
const registerRefreshContent = useEphemeralStore(
state => state.registerRefreshContent,
);
const unregisterRefreshContent = useEphemeralStore(
state => state.unregisterRefreshContent,
);
const [contentRef, {height}] = useMeasure<HTMLIonContentElement>();
// Effects
useEffect(() => {
// Fetch initial content items (non-blocking)
fetchContent(contentRangeLimit, true);
// Register the refresh content function
registerRefreshContent(refreshContent);
return () => {
// Unregister the refresh content function
unregisterRefreshContent(refreshContent);
};
}, []);
// Methods
/**
* Fetch content
* @param limit Limit
* @param reset Whether or not to reset the content items
*/
const fetchContent = async (limit: number, reset: boolean) => {
// Enter critical section
if (fetching) {
return;
}
setFetching(true);
// Record the rank cutoff
if (reset) {
rankCutoff.current = undefined;
}
// Record the start time
const startTime = Date.now();
// Fetch the content
const items = await baseFetchContent(limit, rankCutoff.current);
// Record the end time
const endTime = Date.now();
// Update the state
setContentItems(reset ? items : contentItems.concat(items));
setOutOfContent(items.length < contentRangeLimit);
if (items.length > 0) {
rankCutoff.current = items.at(-1)![contentItemRankKey] as number;
}
fetchLatency.current = endTime - startTime;
// Exit critical section
setFetching(false);
};
/**
* Refresh content, discarding stale content
*/
const refreshContent = async () => {
// Fetch content
await fetchContent(contentRangeLimit, true);
// Reset scroll position
virtualScroller.current?.scrollTo(0);
};
/**
* Update the viewed content items in the visible range
*/
const updatedViewedContentItems = async () => {
// Check if all content items in range have been loaded
const allLoaded = contentItems
.slice(visibleContentRange.current[0], visibleContentRange.current[1])
.every(contentItem =>
loadedContentItems.current.has(contentItem[contentItemIDKey] as string),
);
// Mark all content items in range as viewed
if (allLoaded) {
const results = [];
for (
let i = visibleContentRange.current[0];
i < Math.min(visibleContentRange.current[1], contentItems.length);
i++
) {
const contentItem = contentItems[i]!;
// Skip if the content item has already been viewed
if (
viewedContentItems.current.has(
contentItem[contentItemIDKey] as string,
)
) {
continue;
}
// Update the viewed content items
viewedContentItems.current.add(contentItem[contentItemIDKey] as string);
results.push(
(async () => {
// Call the content item viewed event handler
await onContentItemViewed?.(contentItem);
})(),
);
}
await Promise.all(results);
}
};
/**
* Refresher refresh event handler
* @param event Refresher refresh event
*/
const onRefresherRefresh = async (
event: CustomEvent<RefresherEventDetail>,
) => {
// Refresh content
await refreshContent();
// Call the refresh event handler
await onRefresh?.();
// Complete the refresher
event.detail.complete();
};
/**
* Scroll event handler
* @param offset Offset
*/
const onScroll = async (offset: number) => {
if (virtualScroller.current === null) {
return;
}
// Update the previous scroll metadata
previousScrollMetadatas.current.push({
offset,
timestamp: new Date(),
});
if (previousScrollMetadatas.current.length > maximumScrollMetadatas) {
previousScrollMetadatas.current = previousScrollMetadatas.current.slice(
-maximumScrollMetadatas,
);
}
// Calculate the remaining scroll distance (In pixels)
const remainingDistance =
virtualScroller.current.scrollSize -
virtualScroller.current.viewportSize -
offset;
// Calculate the velocity (In pixels/millisecond)
let velocity = 0;
for (let i = 1; i < previousScrollMetadatas.current.length; i++) {
const a = previousScrollMetadatas.current[i - 1]!;
const b = previousScrollMetadatas.current[i]!;
velocity +=
(b.offset - a.offset) / (b.timestamp.getTime() - a.timestamp.getTime());
}
// Calculate the estimated time to scroll to the bottom (In milliseconds)
const remainingTime = remainingDistance / velocity;
if (
!outOfContent &&
remainingTime > 0 &&
prefetchTimeCoefficient * remainingTime <= fetchLatency.current
) {
// Fetch content
await fetchContent(contentRangeLimit, false);
}
};
/**
* Range change event handler
* @param start Start index
* @param end End index
*/
const onRangeChange = async (start: number, end: number) => {
// Update the visible content range
visibleContentRange.current[0] = start;
visibleContentRange.current[1] = end;
// Update the viewed content items in the visible range
await updatedViewedContentItems();
};
/**
* Content item load event handler
* @param contentItem Content item
*/
const onContentItemLoaded = async (contentItem: T) => {
// Update the loaded content items
loadedContentItems.current.add(contentItem[contentItemIDKey] as string);
// Update the viewed content items in the visible range
await updatedViewedContentItems();
};
return (
<IonContent scrollY={false} ref={contentRef}>
<IonRefresher onIonRefresh={onRefresherRefresh} slot="fixed">
<IonRefresherContent />
</IonRefresher>
<div className="flex flex-col h-full overflow-y-auto w-full">
{contentItems.length > 0 ? (
<VList
className="ion-content-scroll-host overflow-auto"
onScroll={onScroll}
onRangeChange={onRangeChange}
style={{
height,
}}
ref={virtualScroller}
>
{header !== undefined && header}
{contentItems.map((contentItem, index) =>
contentItemRenderer(contentItem, index, () =>
onContentItemLoaded(contentItem),
),
)}
{contentItems.length > 0 && outOfContent && (
<IonItem lines="none">
<p className="mt-6 mb-8 text-center text-xl w-full">
No more {contentItemName}s to see 😢
<br />
<button
aria-label={`Refresh all ${contentItemName}s`}
onClick={refreshContent}
>
<u>Refresh</u>
</button>{" "}
the page to see new {contentItemName}s!
</p>
</IonItem>
)}
{contentItems.length > 0 && fetching && (
<IonInfiniteScroll>
<IonInfiniteScrollContent />
</IonInfiniteScroll>
)}
</VList>
) : (
<>
{header !== undefined && header}
<div className="flex flex-col flex-1 items-center justify-center">
{fetching ? (
<IonSpinner className="h-16 w-16" color="primary" />
) : (
<p className="my-4 text-center text-xl">
No {contentItemName}s to see 😢
<br />
Make a new {contentItemName} to see it here!
</p>
)}
</div>
</>
)}
</div>
</IonContent>
);
};