jump to bottom works!

This commit is contained in:
Michael Peters 2023-01-10 23:34:46 -08:00
parent 29b7c47a7e
commit f5d0240772
8 changed files with 155 additions and 71 deletions

View File

@ -14,7 +14,7 @@ module.exports = {
// syntax // syntax
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'arrow-body-style': ['warn', 'as-needed'], 'arrow-body-style': ['warn', 'as-needed'],
'capitalized-comments': ['warn', 'never', { ignorePattern: 'TODO|WARNING|NOTE' }], // make these statements stand out 'capitalized-comments': ['warn', 'never', { ignorePattern: 'TODO|WARNING|NOTE|LOG' }], // make these statements stand out
// 'curly': [ 'warn', 'multi-line', 'consistent' ], // 'curly': [ 'warn', 'multi-line', 'consistent' ],
'dot-notation': 'warn', 'dot-notation': 'warn',
eqeqeq: 'warn', eqeqeq: 'warn',
@ -154,6 +154,9 @@ module.exports = {
// 'react/jsx-tag-spacing': [ 'warn', { closingSlash: 'never', beforeSelfClosing: 'always', afterOpening: 'never', beforeClosing: 'never' } ], // 'react/jsx-tag-spacing': [ 'warn', { closingSlash: 'never', beforeSelfClosing: 'always', afterOpening: 'never', beforeClosing: 'never' } ],
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': [
'warn',
{ additionalHooks: '(useOneTimeAsyncAction|useAsyncCallback|useAsyncVoidCallback|useAsyncSubmitButton)' },
],
}, },
}; };

View File

@ -1,13 +0,0 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": true,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid"
}

11
src/.prettierrc.yml Normal file
View File

@ -0,0 +1,11 @@
printWidth: 120
tabWidth: 4
useTabs: true
semi: true
singleQuote: true
quoteProps: "as-needed"
jsxSingleQuote: false
trailingComma: "all"
bracketSpacing: true
bracketSameLine: false
arrowParens: "avoid"

View File

@ -1,7 +1,7 @@
import React, { useMemo, MutableRefObject, ReactNode } from 'react'; import React, { useMemo, MutableRefObject, ReactNode } from 'react';
import { LoadableValueScrolling } from '../require/loadables'; import { LoadableValueScrolling } from '../require/loadables';
import { useScrollableCallables } from '../require/react-helper'; import { useAsyncVoidCallback, useScrollableCallables } from '../require/react-helper';
import Retry from './retry'; import Retry from './retry';
export interface InfiniteScrollRecoilProps<T, E> { export interface InfiniteScrollRecoilProps<T, E> {
@ -10,52 +10,71 @@ export interface InfiniteScrollRecoilProps<T, E> {
initialErrorMessage: string; // for if there was a problem loading the initial messages 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 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 belowErrorMessage: string; // for if there was a problem loading messages below the list
bottomErrorMessage: string; // for if there was a problem loading messages below the list
children: ReactNode; children: ReactNode;
} }
function InfiniteScrollRecoil<T>(props: InfiniteScrollRecoilProps<T[], T>) { function InfiniteScrollRecoil<T>(props: InfiniteScrollRecoilProps<T[], T>) {
const { infiniteScrollRef, scrollable, initialErrorMessage, aboveErrorMessage, belowErrorMessage, children } = const {
props; infiniteScrollRef,
scrollable,
initialErrorMessage,
aboveErrorMessage,
belowErrorMessage,
bottomErrorMessage,
children,
} = props;
const { fetchAboveCallable, fetchBelowCallable, onScrollCallable } = useScrollableCallables(scrollable, 600); // activate fetch above/below when 600 client px from the top/bottom const { fetchAboveCallable, fetchBelowCallable, fetchBottomCallable, onScrollCallable } = useScrollableCallables(
scrollable,
600,
); // activate fetch above/below when 600 client px from the top/bottom
const jumpToBottom = useMemo(() => { const [jumpToBottomCallable] = useAsyncVoidCallback(async () => {
if (!scrollable.below?.hasMore) { await fetchBottomCallable();
return null; if (infiniteScrollRef.current) {
} // NOTE: 0 is actually the bottom due to column-reverse
return ( infiniteScrollRef.current.scrollTop = 0;
<div className="jump-to-bottom-wrapper"> }
<div className="jump-to-bottom"> }, [fetchBottomCallable, infiniteScrollRef]);
<div className="text">You are viewing older messages</div> const jumpToBottomElement = useMemo(() => {
<div className="jump" onClick={async () => { console.log('jump to bottom'); }}> if (!scrollable.below?.hasMore) {
Jump to Bottom return null;
</div> }
</div> return (
</div> <div className="jump-to-bottom-wrapper">
); <div className="jump-to-bottom">
}, [scrollable]); <div className="text">You are viewing older messages</div>
<div className="jump" onClick={jumpToBottomCallable}>
Jump to Bottom
</div>
</div>
</div>
);
}, [scrollable, jumpToBottomCallable]);
return ( return (
<div> <div>
<div ref={infiniteScrollRef} className="infinite-scroll-scroll-base" onScroll={onScrollCallable}> <div ref={infiniteScrollRef} className="infinite-scroll-scroll-base" onScroll={onScrollCallable}>
<div className="infinite-scroll-elements"> <div className="infinite-scroll-elements">
<Retry error={scrollable.above?.error} text={aboveErrorMessage} retryFunc={fetchAboveCallable} /> <Retry error={scrollable.above?.error} text={aboveErrorMessage} retryFunc={fetchAboveCallable} />
{children} {children}
<Retry error={scrollable.below?.error} text={belowErrorMessage} retryFunc={fetchBelowCallable} /> <Retry error={scrollable.below?.error} text={belowErrorMessage} retryFunc={fetchBelowCallable} />
<Retry <Retry error={scrollable.bottom?.error} text={bottomErrorMessage} retryFunc={fetchBottomCallable} />
error={scrollable.error} <Retry
text={initialErrorMessage} error={scrollable.error}
// TODO: Allow null instead of noop func to prevent re-renders text={initialErrorMessage}
retryFunc={ // TODO: Allow null instead of noop func to prevent re-renders
scrollable.retry ?? retryFunc={
(async () => { scrollable.retry ??
/* do nothing if we are unloaded */ (async () => {
}) /* do nothing if we are unloaded */
} })
/> }
</div> />
</div> </div>
{jumpToBottom} </div>
</div> {jumpToBottomElement}
</div>
); );
} }

View File

@ -72,6 +72,7 @@ export function createFetchValueScrollingFunc<T, E>(
fetchFunc: (guild: CombinedGuild, count: number) => Promise<Defined<T>>, fetchFunc: (guild: CombinedGuild, count: number) => Promise<Defined<T>>,
createAboveEndFunc: (result: Defined<T>) => LoadableScrollingEnd<E>, createAboveEndFunc: (result: Defined<T>) => LoadableScrollingEnd<E>,
createBelowEndFunc: (result: Defined<T>) => LoadableScrollingEnd<E>, createBelowEndFunc: (result: Defined<T>) => LoadableScrollingEnd<E>,
createBottomEndFunc: (result: Defined<T>) => LoadableScrollingEnd<void>,
): FetchValueScrollingFunc { ): FetchValueScrollingFunc {
const { node, setSelf, getPromise } = atomEffectParam; const { node, setSelf, getPromise } = atomEffectParam;
const fetchValueReferenceFunc = async () => { const fetchValueReferenceFunc = async () => {
@ -94,6 +95,7 @@ export function createFetchValueScrollingFunc<T, E>(
fetchValueReferenceFunc, fetchValueReferenceFunc,
createAboveEndFunc(result), createAboveEndFunc(result),
createBelowEndFunc(result), createBelowEndFunc(result),
createBottomEndFunc(result),
), ),
); );
} catch (e: unknown) { } catch (e: unknown) {
@ -104,24 +106,24 @@ export function createFetchValueScrollingFunc<T, E>(
return fetchValueReferenceFunc; return fetchValueReferenceFunc;
} }
export type FetchValueScrollingReferenceFunc<T> = (reference: T) => Promise<void>; export type FetchValueScrollingReferenceFunc<ET> = (reference: ET) => Promise<void>;
export function createFetchValueScrollingReferenceFunc<T>( export function createFetchValueScrollingReferenceFunc<T, ET extends T | void = T>(
atomEffectParam: AtomEffectParam<LoadableValueScrolling<T[], T>>, atomEffectParam: AtomEffectParam<LoadableValueScrolling<T[], T>>,
guildId: number, guildId: number,
getFunc: (selfState: LoadedValueScrolling<T[], T>) => LoadableScrollingEnd<T>, getFunc: (selfState: LoadedValueScrolling<T[], T>) => LoadableScrollingEnd<ET>,
applyEndToSelf: ( applyEndToSelf: (
selfState: LoadedValueScrolling<T[], T>, selfState: LoadedValueScrolling<T[], T>,
end: LoadableScrollingEnd<T>, end: LoadableScrollingEnd<ET>,
) => LoadedValueScrolling<T[], T>, ) => LoadedValueScrolling<T[], T>,
applyResultToSelf: ( applyResultToSelf: (
selfState: LoadedValueScrolling<T[], T>, selfState: LoadedValueScrolling<T[], T>,
end: LoadedScrollingEnd<T>, end: LoadedScrollingEnd<ET>,
result: T[], result: T[],
) => LoadedValueScrolling<T[], T>, ) => LoadedValueScrolling<T[], T>,
count: number, count: number,
fetchReferenceFunc: (guild: CombinedGuild, reference: T, count: number) => Promise<T[]>, fetchReferenceFunc: (guild: CombinedGuild, reference: ET, count: number) => Promise<T[]>,
): { ): {
fetchValueReferenceFunc: FetchValueScrollingReferenceFunc<T>; fetchValueReferenceFunc: FetchValueScrollingReferenceFunc<ET>;
cancel: () => void; cancel: () => void;
} { } {
const { node, setSelf, getPromise } = atomEffectParam; const { node, setSelf, getPromise } = atomEffectParam;
@ -133,7 +135,7 @@ export function createFetchValueScrollingReferenceFunc<T>(
const cancel = () => { const cancel = () => {
canceled = true; canceled = true;
}; };
const fetchValueReferenceFunc = async (reference: T) => { const fetchValueReferenceFunc = async (reference: ET) => {
const guild = await getPromise(guildState(guildId)); const guild = await getPromise(guildState(guildId));
if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager
@ -144,7 +146,7 @@ export function createFetchValueScrollingReferenceFunc<T>(
if (isEndPended(selfEnd)) return; // don't send a request if we're already loading if (isEndPended(selfEnd)) return; // don't send a request if we're already loading
canceled = false; canceled = false;
selfEnd = createLoadingScrollingEnd(fetchValueReferenceFunc, cancel); selfEnd = createLoadingScrollingEnd<ET>(fetchValueReferenceFunc, cancel);
setSelf(applyEndToSelf(selfState, selfEnd)); setSelf(applyEndToSelf(selfState, selfEnd));
try { try {
const result = await fetchReferenceFunc(guild, reference, count); const result = await fetchReferenceFunc(guild, reference, count);
@ -441,7 +443,9 @@ export function multipleScrollingGuildSubscriptionEffect<
(result: T[]) => (result: T[]) =>
createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), // above end createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), // above end
(_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow), // below end (_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow), // below end
(_result: T[]) => createLoadedScrollingEnd(false, fetchValueBottomReferenceFunc, cancelBottom), // bottom end
); );
// fetch Above a Reference // fetch Above a Reference
const { fetchValueReferenceFunc: fetchValueAboveReferenceFunc, cancel: cancelAbove } = const { fetchValueReferenceFunc: fetchValueAboveReferenceFunc, cancel: cancelAbove } =
createFetchValueScrollingReferenceFunc<T>( createFetchValueScrollingReferenceFunc<T>(
@ -474,6 +478,7 @@ export function multipleScrollingGuildSubscriptionEffect<
fetchCount, fetchCount,
fetchFuncs.fetchAboveFunc, fetchFuncs.fetchAboveFunc,
); );
// fetch Below a Reference // fetch Below a Reference
const { fetchValueReferenceFunc: fetchValueBelowReferenceFunc, cancel: cancelBelow } = const { fetchValueReferenceFunc: fetchValueBelowReferenceFunc, cancel: cancelBelow } =
createFetchValueScrollingReferenceFunc<T>( createFetchValueScrollingReferenceFunc<T>(
@ -507,6 +512,39 @@ export function multipleScrollingGuildSubscriptionEffect<
fetchFuncs.fetchBelowFunc, fetchFuncs.fetchBelowFunc,
); );
// fetch the bottom
const { fetchValueReferenceFunc: fetchValueBottomReferenceFunc, cancel: cancelBottom } =
createFetchValueScrollingReferenceFunc<T, void>(
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.bottom,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<void>) => ({
...selfState,
bottom: end,
}),
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<void>, result: T[]) => {
// make sure to cancel the ends in case they are pending
cancelAbove();
cancelBelow();
const loadedValue: LoadedValueScrolling<T[], T> = {
...selfState,
value: result, // simply overwrite
above: {
...selfState.above,
hasMore: true,
} as LoadableScrollingEnd<T>,
below: {
...selfState.below,
hasMore: false,
} as LoadableScrollingEnd<T>,
bottom: end,
};
return loadedValue;
},
fetchCount,
(guild: CombinedGuild, _reference: void, count: number) => fetchFuncs.fetchBottomFunc(guild, count),
);
// fetch bottom value on first get // fetch bottom value on first get
if (trigger === 'get') { if (trigger === 'get') {
(async () => { (async () => {

View File

@ -172,42 +172,50 @@ export function isEndFailed<T>(
export type UnloadedValueScrolling = UnloadedValue & { export type UnloadedValueScrolling = UnloadedValue & {
above: undefined; above: undefined;
below: undefined; below: undefined;
bottom: undefined;
}; };
export type LoadingValueScrolling = LoadingValue & { export type LoadingValueScrolling = LoadingValue & {
above: undefined; above: undefined;
below: undefined; below: undefined;
bottom: undefined;
}; };
export type LoadedValueScrolling<T, E> = LoadedValue<T> & { export type LoadedValueScrolling<T, E> = LoadedValue<T> & {
above: LoadableScrollingEnd<E>; above: LoadableScrollingEnd<E>;
below: LoadableScrollingEnd<E>; below: LoadableScrollingEnd<E>;
bottom: LoadableScrollingEnd<void>; // void since no reference needed to fetch the bottom
}; };
export type FailedValueScrolling = FailedValue & { export type FailedValueScrolling = FailedValue & {
above: undefined; above: undefined;
below: undefined; below: undefined;
bottom: undefined;
}; };
export type LoadableValueScrolling<T, E> = export type LoadableValueScrolling<T, E> =
| UnloadedValueScrolling | UnloadedValueScrolling
| LoadingValueScrolling | LoadingValueScrolling
| LoadedValueScrolling<T, E> | LoadedValueScrolling<T, E>
| FailedValueScrolling; | FailedValueScrolling;
export const DEF_UNLOADED_SCROLLING_VALUE: UnloadedValueScrolling = { export const DEF_UNLOADED_SCROLLING_VALUE: UnloadedValueScrolling = {
...DEF_UNLOADED_VALUE, ...DEF_UNLOADED_VALUE,
above: undefined, above: undefined,
below: undefined, below: undefined,
bottom: undefined,
}; };
export const DEF_PENDED_SCROLLING_VALUE: LoadingValueScrolling = { export const DEF_PENDED_SCROLLING_VALUE: LoadingValueScrolling = {
...DEF_PENDED_VALUE, ...DEF_PENDED_VALUE,
above: undefined, above: undefined,
below: undefined, below: undefined,
bottom: undefined,
}; };
export function createLoadedValueScrolling<T, E>( export function createLoadedValueScrolling<T, E>(
value: Defined<T>, value: Defined<T>,
retry: () => Promise<void>, retry: () => Promise<void>,
above: LoadableScrollingEnd<E>, above: LoadableScrollingEnd<E>,
below: LoadableScrollingEnd<E>, below: LoadableScrollingEnd<E>,
bottom: LoadableScrollingEnd<void>,
): LoadedValueScrolling<T, E> { ): LoadedValueScrolling<T, E> {
return { ...createLoadedValue(value, retry), above, below }; return { ...createLoadedValue(value, retry), above, below, bottom };
} }
export function createFailedValueScrolling(error: unknown, retry: () => Promise<void>): FailedValueScrolling { export function createFailedValueScrolling(error: unknown, retry: () => Promise<void>): FailedValueScrolling {
return { ...createFailedValue(error, retry), above: undefined, below: undefined }; return { ...createFailedValue(error, retry), above: undefined, below: undefined, bottom: undefined };
} }

View File

@ -142,6 +142,7 @@ export function useAsyncVoidCallback(
const [callable] = useAsyncCallback(async (isMounted: MutableRefObject<boolean>) => { const [callable] = useAsyncCallback(async (isMounted: MutableRefObject<boolean>) => {
await actionFunc(isMounted); await actionFunc(isMounted);
return { errorMessage: null, result: null }; return { errorMessage: null, result: null };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, deps);
return [callable]; return [callable];
} }
@ -240,6 +241,7 @@ export function useDownloadButton(
setText(textMapping.success); setText(textMapping.success);
}, },
// TODO: look into this warning
[downloadName, fetchBuff, ...fetchBuffDeps, filePath, fileBuffer], [downloadName, fetchBuff, ...fetchBuffDeps, filePath, fileBuffer],
); );
@ -268,6 +270,7 @@ export function useAsyncSubmitButton<ResultType>(
if (actionReturnValue.errorMessage === null) setComplete(true); if (actionReturnValue.errorMessage === null) setComplete(true);
return actionReturnValue; return actionReturnValue;
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[...deps, actionFunc], [...deps, actionFunc],
); );
@ -287,13 +290,15 @@ export function useScrollableCallables<T>(
scrollable: LoadableValueScrolling<T[], T>, scrollable: LoadableValueScrolling<T[], T>,
threshold: number, threshold: number,
): { ): {
fetchAboveCallable: () => Promise<void>; // these fetch functions are returned for use in retry buttons fetchAboveCallable: () => Promise<void>; // these fetch functions are returned for use in retry/reset buttons
fetchBelowCallable: () => Promise<void>; fetchBelowCallable: () => Promise<void>;
fetchBottomCallable: () => Promise<void>;
onScrollCallable: (event: UIEvent<HTMLElement>) => void; onScrollCallable: (event: UIEvent<HTMLElement>) => void;
} { } {
const isMounted = useIsMountedRef(); const isMounted = useIsMountedRef();
const [loadingAbove, setLoadingAbove] = useState<boolean>(false); const [loadingAbove, setLoadingAbove] = useState<boolean>(false);
const [loadingBelow, setLoadingBelow] = useState<boolean>(false); const [loadingBelow, setLoadingBelow] = useState<boolean>(false);
const [loadingBottom, setLoadingBottom] = useState<boolean>(false);
const fetchAboveCallable = useCallback(async () => { const fetchAboveCallable = useCallback(async () => {
if (loadingAbove) return; if (loadingAbove) return;
@ -326,6 +331,19 @@ export function useScrollableCallables<T>(
} }
}, [isMounted, loadingBelow, scrollable]); }, [isMounted, loadingBelow, scrollable]);
const fetchBottomCallable = useCallback(async () => {
if (loadingBottom) return;
if (!isLoaded(scrollable)) return;
setLoadingBottom(true);
try {
await scrollable.bottom.fetch();
} finally {
if (isMounted.current) {
setLoadingBottom(false);
}
}
}, [isMounted, loadingBottom, scrollable]);
const onScrollCallable = useCallback( const onScrollCallable = useCallback(
async (event: UIEvent<HTMLElement>) => { async (event: UIEvent<HTMLElement>) => {
const scrollTop = event.currentTarget.scrollTop; const scrollTop = event.currentTarget.scrollTop;
@ -356,7 +374,7 @@ export function useScrollableCallables<T>(
[scrollable, fetchAboveCallable, fetchBelowCallable, threshold], [scrollable, fetchAboveCallable, fetchBelowCallable, threshold],
); );
return { fetchAboveCallable, fetchBelowCallable, onScrollCallable }; return { fetchAboveCallable, fetchBelowCallable, fetchBottomCallable, onScrollCallable };
} }
// makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside // makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside
@ -369,7 +387,7 @@ export function useActionWhenEscapeOrClickedOrContextOutsideEffect(
const data = useRef< const data = useRef<
Map<number, { ref: RefObject<HTMLElement>; mouseDownTarget: Node | null; mouseUpTarget: Node | null }> Map<number, { ref: RefObject<HTMLElement>; mouseDownTarget: Node | null; mouseUpTarget: Node | null }>
>(new Map()); >(new Map());
const [hadMouseDown, setHadMouseDown] = useState<boolean>(false); const [hadMouseDown, setHadMouseDown] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
data.current.clear(); data.current.clear();
@ -381,7 +399,7 @@ export function useActionWhenEscapeOrClickedOrContextOutsideEffect(
const handleClickOutside = useCallback( const handleClickOutside = useCallback(
(event: MouseEvent) => { (event: MouseEvent) => {
if (!hadMouseDown) return; // the click event can fire during the mouse up event that spawns the element that uses this hook if (!hadMouseDown) return; // the click event can fire during the mouse up event that spawns the element that uses this hook
for (const [_idx, { ref, mouseDownTarget, mouseUpTarget }] of data.current) { for (const [_idx, { ref, mouseDownTarget, mouseUpTarget }] of data.current) {
if (!ref.current) return; if (!ref.current) return;
@ -398,7 +416,7 @@ export function useActionWhenEscapeOrClickedOrContextOutsideEffect(
); );
const handleMouseDown = useCallback((event: MouseEvent) => { const handleMouseDown = useCallback((event: MouseEvent) => {
setHadMouseDown(true); setHadMouseDown(true);
for (const [_idx, dataElement] of data.current) { for (const [_idx, dataElement] of data.current) {
if (!dataElement.ref.current) return; if (!dataElement.ref.current) return;
if (dataElement.ref.current.contains(event.target as Node)) { if (dataElement.ref.current.contains(event.target as Node)) {