From 75d585880f5d01fad9fb9b87330fcd02720d0ee1 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 6 Feb 2022 18:48:52 -0600 Subject: [PATCH] more infinite scroll functionality. preparing to use recoil infinite scroll element --- .../components/infinite-scroll-recoil.tsx | 36 ++++++++++ .../elements/components/infinite-scroll.tsx | 1 + .../webapp/elements/components/retry.tsx | 1 + .../webapp/elements/require/atoms-funcs.ts | 16 +++-- .../webapp/elements/require/loadables.ts | 36 +++++----- .../webapp/elements/require/react-helper.tsx | 71 +++++++++++++++++++ 6 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 src/client/webapp/elements/components/infinite-scroll-recoil.tsx diff --git a/src/client/webapp/elements/components/infinite-scroll-recoil.tsx b/src/client/webapp/elements/components/infinite-scroll-recoil.tsx new file mode 100644 index 0000000..23ee498 --- /dev/null +++ b/src/client/webapp/elements/components/infinite-scroll-recoil.tsx @@ -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 { + scrollable: LoadableValueScrolling; + 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(props: InfiniteScrollRecoilProps) { + 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 ( +
+
+ + {children} + {/* do nothing if we are unloaded */})} /> + +
+
+ ); +} + +export default InfiniteScrollRecoil; + diff --git a/src/client/webapp/elements/components/infinite-scroll.tsx b/src/client/webapp/elements/components/infinite-scroll.tsx index d3cb5f9..9b0ec08 100644 --- a/src/client/webapp/elements/components/infinite-scroll.tsx +++ b/src/client/webapp/elements/components/infinite-scroll.tsx @@ -61,3 +61,4 @@ const InfiniteScroll: FC = (props: InfiniteScrollProps) => }; export default InfiniteScroll; + diff --git a/src/client/webapp/elements/components/retry.tsx b/src/client/webapp/elements/components/retry.tsx index 481b55f..10f0d7d 100644 --- a/src/client/webapp/elements/components/retry.tsx +++ b/src/client/webapp/elements/components/retry.tsx @@ -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 diff --git a/src/client/webapp/elements/require/atoms-funcs.ts b/src/client/webapp/elements/require/atoms-funcs.ts index 2acde36..baac234 100644 --- a/src/client/webapp/elements/require/atoms-funcs.ts +++ b/src/client/webapp/elements/require/atoms-funcs.ts @@ -61,6 +61,10 @@ export function createFetchValueScrollingFunc( 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( } { 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) => selfState.above, - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, above: end }), // for "pending, etc" + (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, above: end }), // for "cancelled, pending, etc" (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, 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 = { @@ -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 = { @@ -505,6 +512,7 @@ export function applyListScrollingFuncIfLoaded( // 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 = { diff --git a/src/client/webapp/elements/require/loadables.ts b/src/client/webapp/elements/require/loadables.ts index 96f11e1..482ddb8 100644 --- a/src/client/webapp/elements/require/loadables.ts +++ b/src/client/webapp/elements/require/loadables.ts @@ -65,19 +65,19 @@ export function isLoaded(loadableValue: LoadableValue): loadableValue is L return loadableValue.value !== undefined; } -export interface UnloadedScrollingEnd { +export interface CancelledScrollingEnd { 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; + cancel: () => void; loading: false; } export interface LoadingScrollingEnd { hasMore: undefined | boolean; hasError: undefined; error: undefined; - retry: (reference: T) => Promise; + fetch: (reference: T) => Promise; cancel: () => void; loading: true; } @@ -85,7 +85,7 @@ export interface LoadedScrollingEnd { hasMore: boolean; hasError: false; error: undefined; - retry: (reference: T) => Promise; + fetch: (reference: T) => Promise; cancel: () => void; loading: false; } @@ -93,54 +93,50 @@ export interface FailedScrollingEnd { hasMore: undefined | boolean; hasError: true; error: unknown; - retry: (reference: T) => Promise; + fetch: (reference: T) => Promise; cancel: () => void; loading: false; } -export type LoadableScrollingEnd = UnloadedScrollingEnd | LoadingScrollingEnd | LoadedScrollingEnd | FailedScrollingEnd; -export const DEF_UNLOADED_SCROLL_END: UnloadedScrollingEnd = { hasMore: undefined, hasError: undefined, error: undefined, retry: undefined, cancel: undefined, loading: false }; -export function createLoadingScrollingEnd(retry: (reference: T) => Promise, cancel: () => void): LoadingScrollingEnd { +export type LoadableScrollingEnd = CancelledScrollingEnd | LoadingScrollingEnd | LoadedScrollingEnd | FailedScrollingEnd; +export function createCancelledScrollingEnd(loadingScrollingEnd: LoadingScrollingEnd): CancelledScrollingEnd { + return { ...loadingScrollingEnd, loading: false }; +} +export function createLoadingScrollingEnd(fetch: (reference: T) => Promise, cancel: () => void): LoadingScrollingEnd { return { hasMore: undefined, hasError: undefined, error: undefined, - retry, + fetch, cancel, loading: true }; } -export function createLoadedScrollingEnd(hasMore: boolean, retry: (reference: T) => Promise, cancel: () => void): LoadedScrollingEnd { +export function createLoadedScrollingEnd(hasMore: boolean, fetch: (reference: T) => Promise, cancel: () => void): LoadedScrollingEnd { return { hasMore, hasError: false, error: undefined, - retry, + fetch, cancel, loading: false }; } -export function createFailedScrollingEnd(error: unknown, retry: (reference: T) => Promise, cancel: () => void): FailedScrollingEnd { +export function createFailedScrollingEnd(error: unknown, fetch: (reference: T) => Promise, cancel: () => void): FailedScrollingEnd { return { hasMore: undefined, hasError: true, error, - retry, + fetch, cancel, loading: false }; } -export function isEndUnload(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is UnloadedScrollingEnd { - return loadableScrollingEnd.hasError === undefined && loadableScrollingEnd.loading === false; -} export function isEndPended(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadingScrollingEnd { return loadableScrollingEnd.loading === true; } export function isEndFailed(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is FailedScrollingEnd { return loadableScrollingEnd.hasError === true; } -export function isEndLoaded(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadedScrollingEnd { - return loadableScrollingEnd.hasError === false; -} export type UnloadedValueScrolling = UnloadedValue & { above: undefined; diff --git a/src/client/webapp/elements/require/react-helper.tsx b/src/client/webapp/elements/require/react-helper.tsx index 8ad349a..c2bf86c 100644 --- a/src/client/webapp/elements/require/react-helper.tsx +++ b/src/client/webapp/elements/require/react-helper.tsx @@ -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(scrollable: LoadableValueScrolling, threshold: number): { + fetchAboveCallable: () => Promise, // these fetch functions are returned for use in retry buttons + fetchBelowCallable: () => Promise, + onScrollCallable: (event: UIEvent) => void +} { + const isMounted = useIsMountedRef(); + const [ loadingAbove, setLoadingAbove ] = useState(false); + const [ loadingBelow, setLoadingBelow ] = useState(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) => { + 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 */