more infinite scroll functionality. preparing to use recoil infinite scroll element

This commit is contained in:
Michael Peters 2022-02-06 18:48:52 -06:00
parent 0b4c5356a9
commit 75d585880f
6 changed files with 137 additions and 24 deletions

View File

@ -0,0 +1,36 @@
import React, { ReactNode } from 'react';
import { LoadableValueScrolling } from "../require/loadables";
import { useScrollableCallables } from "../require/react-helper";
import Retry from './retry';
export interface InfiniteScrollRecoilProps<T, E> {
scrollable: LoadableValueScrolling<T, E>;
initialErrorMessage: string; // For if there was a problem loading the initial messages
aboveErrorMessage: string; // For if there was a problem loading messages above the list
belowErrorMessage: string; // For if there was a problem loading messages below the list
children: ReactNode;
}
function InfiniteScrollRecoil<T>(props: InfiniteScrollRecoilProps<T[], T>) {
const { scrollable, initialErrorMessage, aboveErrorMessage, belowErrorMessage, children } = props;
const {
fetchAboveCallable,
fetchBelowCallable,
onScrollCallable
} = useScrollableCallables(scrollable, 600); // Activate fetch above/below when 600 client px from the top/bottom
return (
<div className="infinite-scroll-scroll-base" onScroll={onScrollCallable}>
<div className="infinite-scroll-elements">
<Retry error={scrollable?.above?.error} text={aboveErrorMessage} retryFunc={fetchAboveCallable} />
{children}
<Retry error={scrollable.error} text={initialErrorMessage} retryFunc={scrollable.retry ?? (async () => {/* do nothing if we are unloaded */})} />
<Retry error={scrollable?.below?.error} text={belowErrorMessage} retryFunc={fetchBelowCallable} />
</div>
</div>
);
}
export default InfiniteScrollRecoil;

View File

@ -61,3 +61,4 @@ const InfiniteScroll: FC<InfiniteScrollProps> = (props: InfiniteScrollProps) =>
};
export default InfiniteScroll;

View File

@ -8,6 +8,7 @@ import { useAsyncSubmitButton } from '../require/react-helper';
import Button from './button';
export interface RetryProps {
// TODO: Replace this will the boolean 'hasError'
error: unknown | null,
text: string,
retryFunc: () => Promise<void>

View File

@ -61,6 +61,10 @@ export function createFetchValueScrollingFunc<T, E>(
const selfState = await getPromise(node);
if (isPended(selfState)) return; // Don't send another request if we're already loading
// Cancel any active requests to the top/bottom
if (selfState.above) selfState.above.cancel();
if (selfState.below) selfState.below.cancel();
setSelf(DEF_PENDED_SCROLLING_VALUE);
try {
const result = await fetchFunc(guild, count);
@ -93,8 +97,9 @@ export function createFetchValueScrollingReferenceFunc<T>(
} {
const { node, setSelf, getPromise } = atomEffectParam;
// TODO: Improve cancellation behavior. The way it is now, we have to wait for promises to resolve before we can
// fetch below. On giga-slow internet, this may stink if you fetch messages above, cancel the messages below, and then try to scroll back down to the fetchBottomFunc
// fetch above/below again. On giga-slow internet, this may stink if you fetch messages above, cancel the messages below, and then try to scroll back down to the fetchBottomFunc
// (you'd have to wait for the bottom request to finish before it sends the next fetch)
// This also could show up if you change channels while a fetch is in progress (since it auto-cancels)
let canceled = false;
const cancel = () => { canceled = true; };
const fetchValueReferenceFunc = async (reference: T) => {
@ -371,8 +376,8 @@ export function multipleScrollingGuildSubscriptionEffect<
guildId,
fetchCount,
fetchFuncs.fetchBottomFunc,
(result: T[]) => createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove),
(_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow)
(result: T[]) => createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), // above end
(_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow) // below end
);
// Fetch Above a Reference
const {
@ -382,12 +387,13 @@ export function multipleScrollingGuildSubscriptionEffect<
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.above,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({ ...selfState, above: end }), // for "pending, etc"
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({ ...selfState, above: end }), // for "cancelled, pending, etc"
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => {
let nextValue = result.concat(selfState.value).sort(sortFunc);
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(undefined, maxElements);
cancelBelow();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
@ -418,6 +424,7 @@ export function multipleScrollingGuildSubscriptionEffect<
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(nextValue.length - maxElements, undefined);
cancelAbove();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
@ -505,6 +512,7 @@ export function applyListScrollingFuncIfLoaded<T extends { id: string }, A>(
// This would be very convenient to have. Albeit, new messages *should* be coming to the bottom anyway.
// also, deleted/updated/inserted between messages should (hopefully) not happen very much
nextValue = nextValue.slice(nextValue.length - maxElements, undefined);
selfState.above.cancel(); // Cancel any pending request since we are removing its reference
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {

View File

@ -65,19 +65,19 @@ export function isLoaded<T>(loadableValue: LoadableValue<T>): loadableValue is L
return loadableValue.value !== undefined;
}
export interface UnloadedScrollingEnd {
export interface CancelledScrollingEnd<T> {
hasMore: undefined | boolean; // Could be set to a boolean if we delete from opposite end while adding new elements
hasError: undefined;
error: undefined;
retry: undefined;
cancel: undefined;
fetch: (reference: T) => Promise<void>;
cancel: () => void;
loading: false;
}
export interface LoadingScrollingEnd<T> {
hasMore: undefined | boolean;
hasError: undefined;
error: undefined;
retry: (reference: T) => Promise<void>;
fetch: (reference: T) => Promise<void>;
cancel: () => void;
loading: true;
}
@ -85,7 +85,7 @@ export interface LoadedScrollingEnd<T> {
hasMore: boolean;
hasError: false;
error: undefined;
retry: (reference: T) => Promise<void>;
fetch: (reference: T) => Promise<void>;
cancel: () => void;
loading: false;
}
@ -93,54 +93,50 @@ export interface FailedScrollingEnd<T> {
hasMore: undefined | boolean;
hasError: true;
error: unknown;
retry: (reference: T) => Promise<void>;
fetch: (reference: T) => Promise<void>;
cancel: () => void;
loading: false;
}
export type LoadableScrollingEnd<T> = UnloadedScrollingEnd | LoadingScrollingEnd<T> | LoadedScrollingEnd<T> | FailedScrollingEnd<T>;
export const DEF_UNLOADED_SCROLL_END: UnloadedScrollingEnd = { hasMore: undefined, hasError: undefined, error: undefined, retry: undefined, cancel: undefined, loading: false };
export function createLoadingScrollingEnd<T>(retry: (reference: T) => Promise<void>, cancel: () => void): LoadingScrollingEnd<T> {
export type LoadableScrollingEnd<T> = CancelledScrollingEnd<T> | LoadingScrollingEnd<T> | LoadedScrollingEnd<T> | FailedScrollingEnd<T>;
export function createCancelledScrollingEnd<T>(loadingScrollingEnd: LoadingScrollingEnd<T>): CancelledScrollingEnd<T> {
return { ...loadingScrollingEnd, loading: false };
}
export function createLoadingScrollingEnd<T>(fetch: (reference: T) => Promise<void>, cancel: () => void): LoadingScrollingEnd<T> {
return {
hasMore: undefined,
hasError: undefined,
error: undefined,
retry,
fetch,
cancel,
loading: true
};
}
export function createLoadedScrollingEnd<T>(hasMore: boolean, retry: (reference: T) => Promise<void>, cancel: () => void): LoadedScrollingEnd<T> {
export function createLoadedScrollingEnd<T>(hasMore: boolean, fetch: (reference: T) => Promise<void>, cancel: () => void): LoadedScrollingEnd<T> {
return {
hasMore,
hasError: false,
error: undefined,
retry,
fetch,
cancel,
loading: false
};
}
export function createFailedScrollingEnd<T>(error: unknown, retry: (reference: T) => Promise<void>, cancel: () => void): FailedScrollingEnd<T> {
export function createFailedScrollingEnd<T>(error: unknown, fetch: (reference: T) => Promise<void>, cancel: () => void): FailedScrollingEnd<T> {
return {
hasMore: undefined,
hasError: true,
error,
retry,
fetch,
cancel,
loading: false
};
}
export function isEndUnload<T>(loadableScrollingEnd: LoadableScrollingEnd<T>): loadableScrollingEnd is UnloadedScrollingEnd {
return loadableScrollingEnd.hasError === undefined && loadableScrollingEnd.loading === false;
}
export function isEndPended<T>(loadableScrollingEnd: LoadableScrollingEnd<T>): loadableScrollingEnd is LoadingScrollingEnd<T> {
return loadableScrollingEnd.loading === true;
}
export function isEndFailed<T>(loadableScrollingEnd: LoadableScrollingEnd<T>): loadableScrollingEnd is FailedScrollingEnd<T> {
return loadableScrollingEnd.hasError === true;
}
export function isEndLoaded<T>(loadableScrollingEnd: LoadableScrollingEnd<T>): loadableScrollingEnd is LoadedScrollingEnd<T> {
return loadableScrollingEnd.hasError === false;
}
export type UnloadedValueScrolling = UnloadedValue & {
above: undefined;

View File

@ -13,6 +13,7 @@ import electron from 'electron';
import ElementsUtil, { IAlignment } from './elements-util';
import React from 'react';
import FileDropTarget from '../components/file-drop-target';
import { isLoaded, LoadableValueScrolling } from './loadables';
/** Returns a ref that is true if the component is mounted and false otherwise. Very useful for async stuff */
export function useIsMountedRef() {
@ -320,6 +321,76 @@ export function useColumnReverseInfiniteScroll(
return [ onScrollCallable, loadAbove, loadBelow ];
}
// Returns functions that can be used to fetch the elements above the top element / below the bottom element
export function useScrollableCallables<T>(scrollable: LoadableValueScrolling<T[], T>, threshold: number): {
fetchAboveCallable: () => Promise<void>, // these fetch functions are returned for use in retry buttons
fetchBelowCallable: () => Promise<void>,
onScrollCallable: (event: UIEvent<HTMLElement>) => void
} {
const isMounted = useIsMountedRef();
const [ loadingAbove, setLoadingAbove ] = useState<boolean>(false);
const [ loadingBelow, setLoadingBelow ] = useState<boolean>(false);
const fetchAboveCallable = useCallback(async () => {
if (loadingAbove) return;
setLoadingAbove(true);
if (!isLoaded(scrollable)) return;
if (scrollable.above.hasMore !== true) return; // Don't load unless we know there could be more
if (scrollable.value.length === 0) return; // There's no references available. In this case, hasMore should already have been false/undefined anyway
const topReference = scrollable.value[0] as T;
try {
await scrollable.above.fetch(topReference);
} finally {
if (isMounted.current) {
setLoadingAbove(false);
}
}
}, [ scrollable ]);
const fetchBelowCallable = useCallback(async () => {
if (loadingBelow) return;
setLoadingBelow(true);
if (!isLoaded(scrollable)) return;
if (scrollable.below.hasMore !== true) return; // Don't load unless we know there could be more
if (scrollable.value.length === 0) return; // There's no references available. In this case, hasMore should already have been false/undefined anyway
const bottomReference = scrollable.value[scrollable.value.length - 1] as T;
try {
await scrollable.below.fetch(bottomReference);
} finally {
if (isMounted.current) {
setLoadingBelow(false);
}
}
}, [ scrollable ]);
const onScrollCallable = useCallback(async (event: UIEvent<HTMLElement>) => {
const scrollTop = event.currentTarget.scrollTop;
const scrollHeight = event.currentTarget.scrollHeight;
const clientHeight = event.currentTarget.clientHeight;
// WARNING
// There's likely an inconsistency between browsers on this so have fun when you're working
// on the cross-platform implementation
// scrollTop apparantly is negative for column-reverse divs (this actually kindof makes sense
// if you flip your head upside down)
// I expect this was a change with some version of chromium.
// MDN documentation issue: https://github.com/mdn/content/issues/10968
const distToTop = -(clientHeight - scrollHeight - scrollTop); // keep in mind scrollTop is negative >:]
const distToBottom = -scrollTop;
//LOG.debug(`scroll callable update. to top: ${distToTop}, to bottom: ${distToBottom}`)
if (distToTop < threshold) {
await fetchAboveCallable();
}
if (distToBottom < threshold) {
await fetchBelowCallable();
}
}, [ fetchAboveCallable, fetchBelowCallable, threshold ]);
return { fetchAboveCallable, fetchBelowCallable, onScrollCallable };
}
// Makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside
/** Calls the close action when you hit escape or click outside of the ref element */