infinite scroll messages!

This commit is contained in:
Michael Peters 2021-12-24 17:37:27 -06:00
parent 69c239ebc9
commit 49f3a7096a
11 changed files with 587 additions and 114 deletions

View File

@ -1,4 +1,4 @@
import React, { FC, Ref, useCallback, useMemo } from 'react'; import React, { FC, ReactNode, Ref, useCallback, useMemo } from 'react';
export enum ButtonColorType { export enum ButtonColorType {
BRAND = '', BRAND = '',
@ -14,7 +14,7 @@ interface ButtonProps {
onClick?: () => void; onClick?: () => void;
shaking?: boolean; shaking?: boolean;
children?: React.ReactNode; children?: ReactNode;
} }
const DefaultButtonProps = { const DefaultButtonProps = {

View File

@ -0,0 +1,67 @@
import React, { Dispatch, FC, ReactNode, SetStateAction, useEffect } from 'react';
import ReactHelper from '../require/react-helper';
import Retry from './retry';
export interface InfiniteScrollProps {
fetchRetryCallable: () => Promise<void>;
fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>;
fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>;
setScrollRatio: Dispatch<SetStateAction<number>>;
fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null;
fetchError: unknown | null;
fetchAboveError: unknown | null;
fetchBelowError: unknown | null;
fetchErrorMessage: string;
fetchAboveErrorMessage: string;
fetchBelowErrorMessage: string;
children: ReactNode;
}
// Implements convenient features such as 'try again' components
const InfiniteScroll: FC<InfiniteScrollProps> = (props: InfiniteScrollProps) => {
const {
fetchRetryCallable,
fetchAboveCallable,
fetchBelowCallable,
setScrollRatio,
fetchResult,
fetchError,
fetchAboveError,
fetchBelowError,
fetchErrorMessage,
fetchAboveErrorMessage,
fetchBelowErrorMessage,
children
} = props;
const [
updateScrollCallable,
onLoadCallable,
fetchAboveRetry,
fetchBelowRetry
] = ReactHelper.useColumnReverseInfiniteScroll(
600,
fetchAboveCallable,
fetchBelowCallable,
setScrollRatio
);
useEffect(() => {
if (fetchResult) {
onLoadCallable(fetchResult)
}
}, [ fetchResult, onLoadCallable ]);
return (
<div className="infinite-scroll-scroll-base" onScroll={updateScrollCallable}>
<div className="infinite-scroll-elements">
<Retry error={fetchAboveError} text={fetchAboveErrorMessage} retryFunc={fetchAboveRetry} />
{children}
<Retry error={fetchError} text={fetchErrorMessage} retryFunc={fetchRetryCallable} />
<Retry error={fetchBelowError} text={fetchBelowErrorMessage} retryFunc={fetchBelowRetry} />
</div>
</div>
)
};
export default InfiniteScroll;

View File

@ -0,0 +1,38 @@
import * as electronRemote from '@electron/remote';
const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC } from 'react';
import ReactHelper from '../require/react-helper';
import Button from './button';
export interface RetryProps {
error: unknown | null,
text: string,
retryFunc: () => Promise<void>
}
const Retry: FC<RetryProps> = (props: RetryProps) => {
const { error, text, retryFunc } = props;
const [ retryCallable, buttonText, buttonShaking ] = ReactHelper.useAsyncSubmitButton(
async () => {
await retryFunc(); // error handled by effect
return { result: null, errorMessage: null };
},
[ retryFunc ],
{ start: 'Try Again', pending: 'Fetching...' }
);
if (!error) return null;
return (
<div className="retry">
<div className="text">{text}</div>
<Button onClick={retryCallable} shaking={buttonShaking}>{buttonText}</Button>
</div>
);
}
export default Retry;

View File

@ -26,7 +26,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (props: GuildInvitesDi
const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point
const [ tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild); const [ fetchRetryCallable, tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild);
const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild);

View File

@ -11,7 +11,7 @@ export interface MemberListProps {
const MemberList: FC<MemberListProps> = (props: MemberListProps) => { const MemberList: FC<MemberListProps> = (props: MemberListProps) => {
const { guild } = props; const { guild } = props;
const [ members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild); const [ fetchRetryCallable, members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild);
const memberElements = useMemo(() => { const memberElements = useMemo(() => {
if (fetchError) { if (fetchError) {

View File

@ -3,7 +3,8 @@
$scrollbarBottom: 4px; $scrollbarBottom: 4px;
$borderRadius: 8px; $borderRadius: 8px;
.message-list-anchor { .message-list {
.infinite-scroll-scroll-base {
/* https://stackoverflow.com/q/18614301 */ /* https://stackoverflow.com/q/18614301 */
/* to keep the scrollbar at the bottom by default */ /* to keep the scrollbar at the bottom by default */
box-sizing: border-box; box-sizing: border-box;
@ -15,7 +16,8 @@ $borderRadius: 8px;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
.message-list { .infinite-scroll-elements {
.message-react {
.date-spacer { .date-spacer {
display: flex; display: flex;
align-items: center; align-items: center;
@ -38,8 +40,10 @@ $borderRadius: 8px;
} }
} }
.message-react:hover { &:hover {
background-color: $background-message-hover; background-color: $background-message-hover;
} }
} }
} }
}
}

View File

@ -6,8 +6,9 @@ const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import { Channel, Message } from '../../data-types'; import { Channel, Message } from '../../data-types';
import CombinedGuild from '../../guild-combined'; import CombinedGuild from '../../guild-combined';
import ReactHelper from '../require/react-helper';
import MessageElement from './components/message-element'; import MessageElement from './components/message-element';
import GuildSubscriptions from '../require/guild-subscriptions';
import InfiniteScroll from '../components/infinite-scroll';
interface MessageListProps { interface MessageListProps {
guild: CombinedGuild; guild: CombinedGuild;
@ -17,26 +18,17 @@ interface MessageListProps {
const MessageList: FC<MessageListProps> = (props: MessageListProps) => { const MessageList: FC<MessageListProps> = (props: MessageListProps) => {
const { guild, channel } = props; const { guild, channel } = props;
// TODO: Infinite scroll const [
const [ messages, messagesError ] = ReactHelper.useOneTimeAsyncAction( fetchRetryCallable,
async () => { fetchAboveCallable,
const result = await guild.fetchMessagesRecent(channel.id, 100); fetchBelowCallable,
return result; setScrollRatio,
}, fetchResult,
null, messages,
[ guild, channel ] messagesFetchError,
); messagesFetchAboveError,
messagesFetchBelowError
const pendingElement = useMemo(() => { ] = GuildSubscriptions.useMessagesScrollingSubscription(guild, channel);
if (messages === null) {
if (messagesError === null) {
return <div className="loading">Loading Messages...</div>;
} else {
return <div className="error">Error loading messages...</div>; // TODO: retry
}
}
return null;
}, [ messages, messagesError ]);
const messageElements = useMemo(() => { const messageElements = useMemo(() => {
const result = []; const result = [];
@ -52,9 +44,21 @@ const MessageList: FC<MessageListProps> = (props: MessageListProps) => {
return ( return (
<div className="message-list"> <div className="message-list">
{pendingElement} <InfiniteScroll
{messageElements} fetchRetryCallable={fetchRetryCallable}
fetchAboveCallable={fetchAboveCallable}
fetchBelowCallable={fetchBelowCallable}
setScrollRatio={setScrollRatio}
fetchResult={fetchResult}
fetchError={messagesFetchError}
fetchAboveError={messagesFetchAboveError}
fetchBelowError={messagesFetchBelowError}
fetchErrorMessage="Unable to retrieve recent messages"
fetchAboveErrorMessage="Unable to retrieve messages above"
fetchBelowErrorMessage="Unable to retrieve messages below"
>{messageElements}</InfiniteScroll>
</div> </div>
); );
} }

View File

@ -3,15 +3,17 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger'; import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole); const LOG = Logger.create(__filename, electronConsole);
import { Changes, GuildMetadata, Member, Resource } from "../../data-types"; import { Changes, GuildMetadata, Member, Message, Resource } from "../../data-types";
import CombinedGuild from "../../guild-combined"; import CombinedGuild from "../../guild-combined";
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { AutoVerifierChangesType } from "../../auto-verifier"; import { AutoVerifierChangesType } from "../../auto-verifier";
import { Conflictable, Connectable } from "../../guild-types"; import { Conflictable, Connectable } from "../../guild-types";
import { EventEmitter } from 'tsee'; import { EventEmitter } from 'tsee';
import { IDQuery } from '../../auto-verifier-with-args'; import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args';
import { Token, Channel } from '../../data-types'; import { Token, Channel } from '../../data-types';
import ReactHelper from './react-helper';
import Globals from '../../globals';
export type SingleSubscriptionEvents = { export type SingleSubscriptionEvents = {
'fetch': () => void; 'fetch': () => void;
@ -63,35 +65,32 @@ interface MultipleEventMappingParams<
conflictEventName: CE; conflictEventName: CE;
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>; conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>;
sortFunc: (a: T, b: T) => number; sortFunc: (a: T, b: T) => number; // Friendly reminder that v8 uses timsort so this is O(n) for pre-sorted stuff
} }
export default class GuildSubscriptions { export default class GuildSubscriptions {
private static useGuildSubscriptionEffect<T>( private static useGuildSubscriptionEffect<T>(
isMountedRef: React.MutableRefObject<boolean>,
subscriptionParams: EffectParams<T>, subscriptionParams: EffectParams<T>,
fetchFunc: (() => Promise<T>) | (() => Promise<T | null>) fetchFunc: (() => Promise<T>) | (() => Promise<T | null>)
) { ): [ fetchRetryCallable: () => Promise<void> ] {
const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams;
const fetchManagerFunc = useMemo(() => { const isMounted = ReactHelper.useIsMountedRef();
return async () => {
if (!isMountedRef.current) return; const fetchManagerFunc = useCallback(async () => {
if (!isMounted.current) return;
try { try {
const value = await fetchFunc(); const value = await fetchFunc();
if (!isMountedRef.current) return; if (!isMounted.current) return;
onFetch(value); onFetch(value);
} catch (e: unknown) { } catch (e: unknown) {
LOG.error('error fetching for subscription', e); LOG.error('error fetching for subscription', e);
if (!isMountedRef.current) return; if (!isMounted.current) return;
onFetchError(e); onFetchError(e);
} }
}
}, [ fetchFunc ]); }, [ fetchFunc ]);
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
// Bind guild events to make sure we have the most up to date information // Bind guild events to make sure we have the most up to date information
guild.on('connect', fetchManagerFunc); guild.on('connect', fetchManagerFunc);
bindEventsFunc(); bindEventsFunc();
@ -100,13 +99,13 @@ export default class GuildSubscriptions {
fetchManagerFunc(); fetchManagerFunc();
return () => { return () => {
isMountedRef.current = false;
// Unbind the events so that we don't have any memory leaks // Unbind the events so that we don't have any memory leaks
guild.off('connect', fetchManagerFunc); guild.off('connect', fetchManagerFunc);
unbindEventsFunc(); unbindEventsFunc();
} }
}, [ fetchManagerFunc ]); }, [ fetchManagerFunc ]);
return [ fetchManagerFunc ];
} }
private static useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>( private static useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
@ -116,7 +115,7 @@ export default class GuildSubscriptions {
): [value: T | null, fetchError: unknown | null, events: EventEmitter<SingleSubscriptionEvents>] { ): [value: T | null, fetchError: unknown | null, events: EventEmitter<SingleSubscriptionEvents>] {
const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
const isMountedRef = useRef<boolean>(false); const isMounted = ReactHelper.useIsMountedRef();
const [ fetchError, setFetchError ] = useState<unknown | null>(null); const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ value, setValue ] = useState<T | null>(null); const [ value, setValue ] = useState<T | null>(null);
@ -148,12 +147,12 @@ export default class GuildSubscriptions {
// I think the typed EventEmitter class isn't ready for this level of insane type safety // I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls // otherwise, I may have done this wrong. Forcing it to work with these calls
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => { const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
const value = updatedEventArgsMap(...args); const value = updatedEventArgsMap(...args);
onUpdated(value); onUpdated(value);
}, []) as (Connectable & Conflictable)[UE]; }, []) as (Connectable & Conflictable)[UE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => { const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
const value = conflictEventArgsMap(...args); const value = conflictEventArgsMap(...args);
onConflict(value); onConflict(value);
}, []) as (Connectable & Conflictable)[CE]; }, []) as (Connectable & Conflictable)[CE];
@ -167,7 +166,7 @@ export default class GuildSubscriptions {
guild.off(conflictEventName, boundConflictFunc); guild.off(conflictEventName, boundConflictFunc);
}, []); }, []);
GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, { GuildSubscriptions.useGuildSubscriptionEffect({
guild, guild,
onFetch, onFetch,
onFetchError, onFetchError,
@ -188,7 +187,12 @@ export default class GuildSubscriptions {
guild: CombinedGuild, guild: CombinedGuild,
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>, eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>) fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>)
): [value: T[] | null, fetchError: unknown | null, events: EventEmitter<MultipleSubscriptionEvents<T>>] { ): [
fetchRetryCallable: () => Promise<void>,
value: T[] | null,
fetchError: unknown | null,
events: EventEmitter<MultipleSubscriptionEvents<T>>
] {
const { const {
newEventName, newEventArgsMap, newEventName, newEventArgsMap,
updatedEventName, updatedEventArgsMap, updatedEventName, updatedEventArgsMap,
@ -197,7 +201,7 @@ export default class GuildSubscriptions {
sortFunc sortFunc
} = eventMappingParams; } = eventMappingParams;
const isMountedRef = useRef<boolean>(false); const isMounted = ReactHelper.useIsMountedRef();
const [ fetchError, setFetchError ] = useState<unknown | null>(null); const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ value, setValue ] = useState<T[] | null>(null); const [ value, setValue ] = useState<T[] | null>(null);
@ -209,7 +213,7 @@ export default class GuildSubscriptions {
setValue(fetchValue); setValue(fetchValue);
setFetchError(null); setFetchError(null);
events.emit('fetch'); events.emit('fetch');
}, []); }, [ sortFunc ]);
const onFetchError = useCallback((e: unknown) => { const onFetchError = useCallback((e: unknown) => {
setFetchError(e); setFetchError(e);
@ -223,14 +227,14 @@ export default class GuildSubscriptions {
return currentValue.concat(newElements).sort(sortFunc); return currentValue.concat(newElements).sort(sortFunc);
}) })
events.emit('new', newElements); events.emit('new', newElements);
}, []); }, [ sortFunc ]);
const onUpdated = useCallback((updatedElements: T[]) => { const onUpdated = useCallback((updatedElements: T[]) => {
setValue(currentValue => { setValue(currentValue => {
if (currentValue === null) return null; if (currentValue === null) return null;
return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
}); });
events.emit('updated', updatedElements); events.emit('updated', updatedElements);
}, []); }, [ sortFunc ]);
const onRemoved = useCallback((removedElements: T[]) => { const onRemoved = useCallback((removedElements: T[]) => {
setValue(currentValue => { setValue(currentValue => {
if (currentValue === null) return null; if (currentValue === null) return null;
@ -238,7 +242,7 @@ export default class GuildSubscriptions {
return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc); return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc);
}); });
events.emit('removed', removedElements); events.emit('removed', removedElements);
}, []); }, [ sortFunc ]);
const onConflict = useCallback((changes: Changes<T>) => { const onConflict = useCallback((changes: Changes<T>) => {
setValue(currentValue => { setValue(currentValue => {
@ -251,41 +255,41 @@ export default class GuildSubscriptions {
.sort(sortFunc); .sort(sortFunc);
}); });
events.emit('conflict', changes); events.emit('conflict', changes);
}, []); }, [ sortFunc ]);
// I think the typed EventEmitter class isn't ready for this level of insane type safety // I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls // otherwise, I may have done this wrong. Forcing it to work with these calls
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => { const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
onNew(newEventArgsMap(...args)); onNew(newEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[NE]; }, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => { const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
onUpdated(updatedEventArgsMap(...args)); onUpdated(updatedEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[UE]; }, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => { const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
onRemoved(removedEventArgsMap(...args)); onRemoved(removedEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[RE]; }, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => { const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMountedRef.current) return; if (!isMounted.current) return;
onConflict(conflictEventArgsMap(...args)); onConflict(conflictEventArgsMap(...args));
}, []) as (Connectable & Conflictable)[CE]; }, [ onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
const bindEventsFunc = useCallback(() => { const bindEventsFunc = useCallback(() => {
guild.on(newEventName, boundNewFunc); guild.on(newEventName, boundNewFunc);
guild.on(updatedEventName, boundUpdateFunc); guild.on(updatedEventName, boundUpdateFunc);
guild.on(removedEventName, boundRemovedFunc); guild.on(removedEventName, boundRemovedFunc);
guild.on(conflictEventName, boundConflictFunc); guild.on(conflictEventName, boundConflictFunc);
}, []); }, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const unbindEventsFunc = useCallback(() => { const unbindEventsFunc = useCallback(() => {
guild.off(newEventName, boundNewFunc); guild.off(newEventName, boundNewFunc);
guild.off(updatedEventName, boundUpdateFunc); guild.off(updatedEventName, boundUpdateFunc);
guild.off(removedEventName, boundRemovedFunc); guild.off(removedEventName, boundRemovedFunc);
guild.off(conflictEventName, boundConflictFunc); guild.off(conflictEventName, boundConflictFunc);
}, []); }, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, { const [ fetchRetryCallable ] = GuildSubscriptions.useGuildSubscriptionEffect({
guild, guild,
onFetch, onFetch,
onFetchError, onFetchError,
@ -293,7 +297,253 @@ export default class GuildSubscriptions {
unbindEventsFunc unbindEventsFunc
}, fetchFunc); }, fetchFunc);
return [ value, fetchError, events ]; return [ fetchRetryCallable, value, fetchError, events ];
}
private static useMultipleGuildSubscriptionScrolling<
T extends { id: string },
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable
>(
guild: CombinedGuild,
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
maxElements: number,
maxFetchElements: number,
fetchFunc: (() => Promise<T[]>) | (() => Promise<T[] | null>),
fetchAboveFunc: ((reference: T) => Promise<T[] | null>),
fetchBelowFunc: ((reference: T) => Promise<T[] | null>),
): [
fetchRetryCallable: () => Promise<void>,
fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>,
fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>,
setScrollRatio: Dispatch<SetStateAction<number>>,
fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null,
value: T[] | null,
fetchError: unknown | null,
fetchAboveError: unknown | null,
fetchBelowError: unknown | null,
events: EventEmitter<MultipleSubscriptionEvents<T>>
] {
const {
newEventName, newEventArgsMap,
updatedEventName, updatedEventArgsMap,
removedEventName, removedEventArgsMap,
conflictEventName, conflictEventArgsMap,
sortFunc
} = eventMappingParams;
const isMounted = ReactHelper.useIsMountedRef();
const [ value, setValue ] = useState<T[] | null>(null);
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ fetchAboveError, setFetchAboveError ] = useState<unknown | null>(null);
const [ fetchBelowError, setFetchBelowError ] = useState<unknown | null>(null);
const [ fetchResult, setFetchResult ] = useState<{ hasMoreAbove: boolean, hasMoreBelow: boolean } | null>(null);
// Percentage of scroll from top. i.e. 300px from top of 1000px scroll = 0.3 scroll ratio
const [ scrollRatio, setScrollRatio ] = useState<number>(0.5);
// Gets the number of elements to remove from the top or bottom. Tries to optimise so that if 10 elements
// are removed and we're 30% scrolled down, 3 get removed from the top and 7 are removed from the bottom.
// TBH, this function is pretty overkill but that's important
const getRemoveCounts = useCallback((toRemove: number): { fromTop: number, fromBottom: number } => {
// Make sure we round toward the side of the scrollbar that we are not closest to
// this is important for the most common case of 1 removed element.
const topRoundFunc = scrollRatio > 0.5 ? Math.ceil : Math.floor;
const bottomRoundFunc = scrollRatio > 0.5 ? Math.floor : Math.ceil;
return {
fromTop: topRoundFunc(toRemove * scrollRatio),
fromBottom: bottomRoundFunc(toRemove * scrollRatio)
}
}, [ scrollRatio ]);
function removeByCounts<T>(elements: T[], counts: { fromTop: number, fromBottom: number }): T[] {
const { fromTop, fromBottom } = counts;
return elements.slice(fromTop, elements.length - fromBottom);
}
const events = useMemo(() => new EventEmitter<MultipleSubscriptionEvents<T>>(), []);
const fetchAboveCallable = useCallback(async (): Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }> => {
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
if (!value || value.length === 0) return { hasMoreAbove: false, removedFromBottom: false };
try {
const reference = value[0] as T;
const aboveElements = await fetchAboveFunc(reference);
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
setFetchAboveError(null);
if (aboveElements) {
const hasMoreAbove = aboveElements.length >= maxFetchElements;
let removedFromBottom = false;
setValue(currentValue => {
let newValue = aboveElements.concat(currentValue ?? []).sort(sortFunc);
if (newValue.length > maxElements) {
newValue = newValue.slice(0, maxElements);
removedFromBottom = true;
}
return newValue;
});
return { hasMoreAbove, removedFromBottom };
} else {
return { hasMoreAbove: false, removedFromBottom: false };
}
} catch (e: unknown) {
LOG.error('error fetching above for subscription', e);
if (!isMounted.current) return { hasMoreAbove: false, removedFromBottom: false };
setFetchAboveError(e);
return { hasMoreAbove: true, removedFromBottom: false };
}
}, [ value, fetchAboveFunc, maxFetchElements ]);
const fetchBelowCallable = useCallback(async (): Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }> => {
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
if (!value || value.length === 0) return { hasMoreBelow: false, removedFromTop: false };
try {
const reference = value[value.length - 1] as T;
const belowElements = await fetchBelowFunc(reference);
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
setFetchBelowError(null);
if (belowElements) {
const hasMoreBelow = belowElements.length >= maxFetchElements;
let removedFromTop = false;
setValue(currentValue => {
let newValue = (currentValue ?? []).concat(belowElements).sort(sortFunc);
if (newValue.length > maxElements) {
newValue = newValue.slice(Math.max(newValue.length - maxElements, 0));
removedFromTop = true;
}
return newValue;
});
return { hasMoreBelow, removedFromTop };
} else {
return { hasMoreBelow: false, removedFromTop: false };
}
} catch (e: unknown) {
LOG.error('error fetching below for subscription', e);
if (!isMounted.current) return { hasMoreBelow: false, removedFromTop: false };
setFetchBelowError(e);
return { hasMoreBelow: true, removedFromTop: false };
}
}, [ value, fetchBelowFunc, maxFetchElements ]);
const onFetch = useCallback((fetchValue: T[] | null) => {
let hasMoreAbove = false;
if (fetchValue) {
if (fetchValue.length >= maxFetchElements) hasMoreAbove = true;
fetchValue = fetchValue.slice(Math.max(fetchValue.length - maxElements)).sort(sortFunc);
}
setFetchResult({ hasMoreAbove, hasMoreBelow: false });
setValue(fetchValue);
setFetchError(null);
events.emit('fetch');
}, [ sortFunc, maxFetchElements, maxElements ]);
const onFetchError = useCallback((e: unknown) => {
setFetchError(e);
setValue(null);
events.emit('fetch-error');
}, []);
const onNew = useCallback((newElements: T[]) => {
setValue(currentValue => {
if (currentValue === null) return null;
let newValue = currentValue.concat(newElements).sort(sortFunc);
if (newValue.length > maxElements) {
newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements));
}
return newValue;
})
events.emit('new', newElements);
}, [ sortFunc, getRemoveCounts ]);
const onUpdated = useCallback((updatedElements: T[]) => {
setValue(currentValue => {
if (currentValue === null) return null;
return currentValue.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
});
events.emit('updated', updatedElements);
}, [ sortFunc ]);
const onRemoved = useCallback((removedElements: T[]) => {
setValue(currentValue => {
if (currentValue === null) return null;
const deletedIds = new Set(removedElements.map(deletedElement => deletedElement.id));
return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc);
});
events.emit('removed', removedElements);
}, [ sortFunc ]);
const onConflict = useCallback((changes: Changes<T>) => {
setValue(currentValue => {
if (currentValue === null) return null;
const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id));
let newValue = currentValue
.concat(changes.added)
.map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element)
.filter(element => !deletedIds.has(element.id))
.sort(sortFunc);
if (newValue.length > maxElements) {
newValue = removeByCounts(newValue, getRemoveCounts(newValue.length - maxElements));
}
return newValue;
});
events.emit('conflict', changes);
}, [ sortFunc, getRemoveCounts ]);
// I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
if (!isMounted.current) return;
onNew(newEventArgsMap(...args));
}, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMounted.current) return;
onUpdated(updatedEventArgsMap(...args));
}, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
if (!isMounted.current) return;
onRemoved(removedEventArgsMap(...args));
}, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMounted.current) return;
onConflict(conflictEventArgsMap(...args));
}, [ onConflict, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
const bindEventsFunc = useCallback(() => {
guild.on(newEventName, boundNewFunc);
guild.on(updatedEventName, boundUpdateFunc);
guild.on(removedEventName, boundRemovedFunc);
guild.on(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const unbindEventsFunc = useCallback(() => {
guild.off(newEventName, boundNewFunc);
guild.off(updatedEventName, boundUpdateFunc);
guild.off(removedEventName, boundRemovedFunc);
guild.off(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const [ fetchRetryCallable ] = GuildSubscriptions.useGuildSubscriptionEffect({
guild,
onFetch,
onFetchError,
bindEventsFunc,
unbindEventsFunc
}, fetchFunc);
return [
fetchRetryCallable,
fetchAboveCallable,
fetchBelowCallable,
setScrollRatio,
fetchResult,
value,
fetchError,
fetchAboveError,
fetchBelowError,
events
];
} }
static useGuildMetadataSubscription(guild: CombinedGuild) { static useGuildMetadataSubscription(guild: CombinedGuild) {
@ -374,4 +624,33 @@ export default class GuildSubscriptions {
sortFunc: Token.sortRecentCreatedFirst sortFunc: Token.sortRecentCreatedFirst
}, fetchTokensFunc); }, fetchTokensFunc);
} }
static useMessagesScrollingSubscription(guild: CombinedGuild, channel: Channel) {
const maxFetchElements = Globals.MESSAGES_PER_REQUEST;
const maxElements = Globals.MAX_CURRENT_MESSAGES;
const fetchMessagesFunc = useCallback(async () => {
return await guild.fetchMessagesRecent(channel.id, maxFetchElements);
}, [ guild, channel.id, maxFetchElements ]);
const fetchAboveFunc = useCallback(async (reference: Message) => {
return await guild.fetchMessagesBefore(channel.id, reference.id, maxFetchElements);
}, [ guild, channel.id, maxFetchElements ]);
const fetchBelowFunc = useCallback(async (reference: Message) => {
return await guild.fetchMessagesAfter(channel.id, reference.id, maxFetchElements);
}, [ guild, channel.id, maxFetchElements ]);
return GuildSubscriptions.useMultipleGuildSubscriptionScrolling(
guild, {
newEventName: 'new-messages',
newEventArgsMap: (messages: Message[]) => messages,
updatedEventName: 'update-messages',
updatedEventArgsMap: (updatedMessages) => updatedMessages,
removedEventName: 'remove-messages',
removedEventArgsMap: (removedMessages) => removedMessages,
conflictEventName: 'conflict-messages',
conflictEventArgsMap: (query: PartialMessageListQuery, changesType: AutoVerifierChangesType, changes: Changes<Message>) => changes,
sortFunc: Message.sortOrder
},
maxElements, maxFetchElements,
fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc
)
}
} }

View File

@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger'; import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole); const LOG = Logger.create(__filename, electronConsole);
import { DependencyList, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DependencyList, Dispatch, MutableRefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactDOMServer from "react-dom/server"; import ReactDOMServer from "react-dom/server";
import { ShouldNeverHappenError } from "../../data-types"; import { ShouldNeverHappenError } from "../../data-types";
import Util from '../../util'; import Util from '../../util';
@ -261,4 +261,80 @@ export default class ReactHelper {
return [ callable, text, shaking, result, errorMessage ]; return [ callable, text, shaking, result, errorMessage ];
} }
static useColumnReverseInfiniteScroll(
threshold: number,
loadMoreAbove: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>,
loadMoreBelow: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>,
setScrollRatio: Dispatch<SetStateAction<number>>
): [
updateCallable: (event: UIEvent<HTMLElement>) => void,
onLoadCallable: (params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => void,
loadAboveRetry: () => Promise<void>,
loadBelowRetry: () => Promise<void>
] {
const isMounted = ReactHelper.useIsMountedRef();
const [ loadingAbove, setLoadingAbove ] = useState<boolean>(false);
const [ loadingBelow, setLoadingBelow ] = useState<boolean>(false);
const [ hasMoreAbove, setHasMoreAbove ] = useState<boolean>(false);
const [ hasMoreBelow, setHasMoreBelow ] = useState<boolean>(false);
const loadAbove = useCallback(async () => {
if (loadingAbove || !hasMoreAbove) return;
setLoadingAbove(true);
const loadResult = await loadMoreAbove();
if (!isMounted.current) return;
setHasMoreAbove(loadResult.hasMoreAbove);
setHasMoreBelow(oldHasMoreBelow => oldHasMoreBelow || loadResult.removedFromBottom);
setLoadingAbove(false);
}, [ loadingAbove, hasMoreAbove, loadMoreAbove ]);
const loadBelow = useCallback(async () => {
if (loadingBelow || !hasMoreBelow) return;
setLoadingBelow(true);
const loadResult = await loadMoreBelow();
if (!isMounted.current) return;
setHasMoreBelow(loadResult.hasMoreBelow);
setHasMoreAbove(oldHasMoreAbove => oldHasMoreAbove || loadResult.removedFromTop);
setLoadingBelow(false);
}, [ loadingBelow, hasMoreBelow, loadMoreBelow ]);
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 of this
// scrollTop apparantly is negative for column-reverse divs (this actually kindof makes sense if you flip your head upside down)
// have to reverse this
// I expect this was a change with some version of chromium.
// MDN documentation issue: https://github.com/mdn/content/issues/10968
setScrollRatio(Math.abs(scrollTop / scrollHeight));
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 loadAbove();
}
if (distToBottom < threshold) {
await loadBelow();
}
}, [ setScrollRatio, loadAbove, loadBelow, threshold ]);
const onLoadCallable = useCallback((params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => {
setHasMoreAbove(params.hasMoreAbove);
setHasMoreBelow(params.hasMoreBelow);
}, []);
return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ];
}
} }

View File

@ -1,6 +1,11 @@
import { Message, ShouldNeverHappenError } from "./data-types"; import { Message, ShouldNeverHappenError } from "./data-types";
import Globals from "./globals"; import Globals from "./globals";
import * as electronRemote from '@electron/remote';
const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
interface MessagesWithMetadata { interface MessagesWithMetadata {
messages: Map<string, Message>; messages: Map<string, Message>;
totalCharacters: number; totalCharacters: number;
@ -87,7 +92,7 @@ export default class MessageRAMCache {
if (!value) throw new ShouldNeverHappenError('unable to get message map'); if (!value) throw new ShouldNeverHappenError('unable to get message map');
value.lastUsed = new Date(); value.lastUsed = new Date();
const allRecentMessages = Array.from(value.messages.values()).sort(Message.sortOrder); const allRecentMessages = Array.from(value.messages.values()).sort(Message.sortOrder);
const start = Math.min(allRecentMessages.length - number, 0); const start = Math.max(allRecentMessages.length - number, 0);
const result = allRecentMessages.slice(start); const result = allRecentMessages.slice(start);
if (result.length === 0) { if (result.length === 0) {
return null; return null;

View File

@ -3,12 +3,12 @@
/* channel feed custom scrollbar */ /* channel feed custom scrollbar */
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */ /* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
#channel-feed-content-wrapper::-webkit-scrollbar, #channel-feed-content-wrapper::-webkit-scrollbar,
.message-list-anchor::-webkit-scrollbar { .message-list .infinite-scroll-scroll-base::-webkit-scrollbar {
width: 16px; width: 16px;
} }
#channel-feed-content-wrapper::-webkit-scrollbar-track, #channel-feed-content-wrapper::-webkit-scrollbar-track,
.message-list-anchor::-webkit-scrollbar-track { .message-list .infinite-scroll-scroll-base::-webkit-scrollbar-track {
visibility: visible; /* always visible, does not disappear when not hovered over */ visibility: visible; /* always visible, does not disappear when not hovered over */
box-shadow: 0 0 12px 12px $background-secondary inset; box-shadow: 0 0 12px 12px $background-secondary inset;
border: 4px solid transparent; border: 4px solid transparent;
@ -16,7 +16,7 @@
} }
#channel-feed-content-wrapper::-webkit-scrollbar-thumb, #channel-feed-content-wrapper::-webkit-scrollbar-thumb,
.message-list-anchor::-webkit-scrollbar-thumb { .message-list .infinite-scroll-scroll-base::-webkit-scrollbar-thumb {
visibility: visible; /* always visible, does not disappear when not hovered over */ visibility: visible; /* always visible, does not disappear when not hovered over */
box-shadow: 0 0 12px 12px $background-tertiary inset; box-shadow: 0 0 12px 12px $background-tertiary inset;
border: 4px solid transparent; border: 4px solid transparent;
@ -24,14 +24,14 @@
} }
#channel-feed-content-wrapper::-webkit-scrollbar-button, #channel-feed-content-wrapper::-webkit-scrollbar-button,
.message-list-anchor::-webkit-scrollbar-button { .message-list .infinite-scroll-scroll-base::-webkit-scrollbar-button {
display: none; display: none;
} }
/* General custom scrollbar (much thinner than channel feed) */ /* General custom scrollbar (much thinner than channel feed) */
/* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */ /* Note: Using this hack... https://stackoverflow.com/questions/29866759/how-do-i-add-a-margin-to-a-css-webkit-scrollbar */
:not(:hover)::-webkit-scrollbar-thumb { :not(.message-list .infinite-scroll-scroll-base):not(:hover)::-webkit-scrollbar-thumb {
visibility: hidden; visibility: hidden;
} }