diff --git a/src/client/webapp/elements/components/button.tsx b/src/client/webapp/elements/components/button.tsx index ae211e4..cd4871f 100644 --- a/src/client/webapp/elements/components/button.tsx +++ b/src/client/webapp/elements/components/button.tsx @@ -1,4 +1,4 @@ -import React, { FC, Ref, useCallback, useMemo } from 'react'; +import React, { FC, ReactNode, Ref, useCallback, useMemo } from 'react'; export enum ButtonColorType { BRAND = '', @@ -14,7 +14,7 @@ interface ButtonProps { onClick?: () => void; shaking?: boolean; - children?: React.ReactNode; + children?: ReactNode; } const DefaultButtonProps = { diff --git a/src/client/webapp/elements/components/infinite-scroll.tsx b/src/client/webapp/elements/components/infinite-scroll.tsx new file mode 100644 index 0000000..c55156b --- /dev/null +++ b/src/client/webapp/elements/components/infinite-scroll.tsx @@ -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; + fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>; + fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>; + setScrollRatio: Dispatch>; + 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 = (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 ( +
+
+ + {children} + + +
+
+ ) +}; + +export default InfiniteScroll; diff --git a/src/client/webapp/elements/components/retry.tsx b/src/client/webapp/elements/components/retry.tsx new file mode 100644 index 0000000..2259b9a --- /dev/null +++ b/src/client/webapp/elements/components/retry.tsx @@ -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 +} + +const Retry: FC = (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 ( +
+
{text}
+ +
+ ); +} + +export default Retry; diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 1fee0f0..c386a9f 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -26,7 +26,7 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi 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); diff --git a/src/client/webapp/elements/lists/member-list.tsx b/src/client/webapp/elements/lists/member-list.tsx index e2cff50..7dfb809 100644 --- a/src/client/webapp/elements/lists/member-list.tsx +++ b/src/client/webapp/elements/lists/member-list.tsx @@ -11,7 +11,7 @@ export interface MemberListProps { const MemberList: FC = (props: MemberListProps) => { const { guild } = props; - const [ members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild); + const [ fetchRetryCallable, members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild); const memberElements = useMemo(() => { if (fetchError) { diff --git a/src/client/webapp/elements/lists/message-list.scss b/src/client/webapp/elements/lists/message-list.scss index 3faa366..d169805 100644 --- a/src/client/webapp/elements/lists/message-list.scss +++ b/src/client/webapp/elements/lists/message-list.scss @@ -3,43 +3,47 @@ $scrollbarBottom: 4px; $borderRadius: 8px; -.message-list-anchor { - /* https://stackoverflow.com/q/18614301 */ - /* to keep the scrollbar at the bottom by default */ - box-sizing: border-box; - display: flex; - flex-direction: column-reverse; - padding-bottom: calc(16px + 8px); - margin-bottom: calc(65px - $scrollbarBottom - $borderRadius); - height: calc(100vh - 71px - 65px + 4px + 8px); /* TODO: Going to have to find a way to do this without a fixed height since the message box needs to be scalable */ - overflow-y: scroll; - overflow-x: hidden; +.message-list { + .infinite-scroll-scroll-base { + /* https://stackoverflow.com/q/18614301 */ + /* to keep the scrollbar at the bottom by default */ + box-sizing: border-box; + display: flex; + flex-direction: column-reverse; + padding-bottom: calc(16px + 8px); + margin-bottom: calc(65px - $scrollbarBottom - $borderRadius); + height: calc(100vh - 71px - 65px + 4px + 8px); /* TODO: Going to have to find a way to do this without a fixed height since the message box needs to be scalable */ + overflow-y: scroll; + overflow-x: hidden; - .message-list { - .date-spacer { - display: flex; - align-items: center; - padding-left: 16px; - padding-right: 16px; - - .line { - flex: 1; - height: 1; - background-color: $background-modifier-accent; + .infinite-scroll-elements { + .message-react { + .date-spacer { + display: flex; + align-items: center; + padding-left: 16px; + padding-right: 16px; + + .line { + flex: 1; + height: 1; + background-color: $background-modifier-accent; + } + + .date { + font-size: 12px; + line-height: 13px; + font-weight: 600; + color: $text-muted; + padding: 2px 4px; + margin: 0 8px; + } + } + + &:hover { + background-color: $background-message-hover; + } } - - .date { - font-size: 12px; - line-height: 13px; - font-weight: 600; - color: $text-muted; - padding: 2px 4px; - margin: 0 8px; - } - } - - .message-react:hover { - background-color: $background-message-hover; } } } diff --git a/src/client/webapp/elements/lists/message-list.tsx b/src/client/webapp/elements/lists/message-list.tsx index 11bac04..69d1be8 100644 --- a/src/client/webapp/elements/lists/message-list.tsx +++ b/src/client/webapp/elements/lists/message-list.tsx @@ -6,8 +6,9 @@ const LOG = Logger.create(__filename, electronConsole); import React, { FC, useMemo } from 'react'; import { Channel, Message } from '../../data-types'; import CombinedGuild from '../../guild-combined'; -import ReactHelper from '../require/react-helper'; import MessageElement from './components/message-element'; +import GuildSubscriptions from '../require/guild-subscriptions'; +import InfiniteScroll from '../components/infinite-scroll'; interface MessageListProps { guild: CombinedGuild; @@ -17,26 +18,17 @@ interface MessageListProps { const MessageList: FC = (props: MessageListProps) => { const { guild, channel } = props; - // TODO: Infinite scroll - const [ messages, messagesError ] = ReactHelper.useOneTimeAsyncAction( - async () => { - const result = await guild.fetchMessagesRecent(channel.id, 100); - return result; - }, - null, - [ guild, channel ] - ); - - const pendingElement = useMemo(() => { - if (messages === null) { - if (messagesError === null) { - return
Loading Messages...
; - } else { - return
Error loading messages...
; // TODO: retry - } - } - return null; - }, [ messages, messagesError ]); + const [ + fetchRetryCallable, + fetchAboveCallable, + fetchBelowCallable, + setScrollRatio, + fetchResult, + messages, + messagesFetchError, + messagesFetchAboveError, + messagesFetchBelowError + ] = GuildSubscriptions.useMessagesScrollingSubscription(guild, channel); const messageElements = useMemo(() => { const result = []; @@ -52,9 +44,21 @@ const MessageList: FC = (props: MessageListProps) => { return (
- {pendingElement} - {messageElements} + {messageElements}
+ ); } diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index 0f384da..89abb9f 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -3,15 +3,17 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { AutoVerifierChangesType } from "../../auto-verifier"; import { Conflictable, Connectable } from "../../guild-types"; 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 ReactHelper from './react-helper'; +import Globals from '../../globals'; export type SingleSubscriptionEvents = { 'fetch': () => void; @@ -63,35 +65,32 @@ interface MultipleEventMappingParams< conflictEventName: CE; conflictEventArgsMap: (...args: Arguments) => Changes; - 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 { private static useGuildSubscriptionEffect( - isMountedRef: React.MutableRefObject, subscriptionParams: EffectParams, fetchFunc: (() => Promise) | (() => Promise) - ) { + ): [ fetchRetryCallable: () => Promise ] { const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; - const fetchManagerFunc = useMemo(() => { - return async () => { - if (!isMountedRef.current) return; - try { - const value = await fetchFunc(); - if (!isMountedRef.current) return; - onFetch(value); - } catch (e: unknown) { - LOG.error('error fetching for subscription', e); - if (!isMountedRef.current) return; - onFetchError(e); - } + const isMounted = ReactHelper.useIsMountedRef(); + + const fetchManagerFunc = useCallback(async () => { + if (!isMounted.current) return; + try { + const value = await fetchFunc(); + if (!isMounted.current) return; + onFetch(value); + } catch (e: unknown) { + LOG.error('error fetching for subscription', e); + if (!isMounted.current) return; + onFetchError(e); } }, [ fetchFunc ]); useEffect(() => { - isMountedRef.current = true; - // Bind guild events to make sure we have the most up to date information guild.on('connect', fetchManagerFunc); bindEventsFunc(); @@ -100,13 +99,13 @@ export default class GuildSubscriptions { fetchManagerFunc(); return () => { - isMountedRef.current = false; - // Unbind the events so that we don't have any memory leaks guild.off('connect', fetchManagerFunc); unbindEventsFunc(); } }, [ fetchManagerFunc ]); + + return [ fetchManagerFunc ]; } private static useSingleGuildSubscription( @@ -116,7 +115,7 @@ export default class GuildSubscriptions { ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; - const isMountedRef = useRef(false); + const isMounted = ReactHelper.useIsMountedRef(); const [ fetchError, setFetchError ] = useState(null); const [ value, setValue ] = useState(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 // otherwise, I may have done this wrong. Forcing it to work with these calls const boundUpdateFunc = useCallback((...args: Arguments): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; const value = updatedEventArgsMap(...args); onUpdated(value); }, []) as (Connectable & Conflictable)[UE]; const boundConflictFunc = useCallback((...args: Arguments): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; const value = conflictEventArgsMap(...args); onConflict(value); }, []) as (Connectable & Conflictable)[CE]; @@ -167,7 +166,7 @@ export default class GuildSubscriptions { guild.off(conflictEventName, boundConflictFunc); }, []); - GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, { + GuildSubscriptions.useGuildSubscriptionEffect({ guild, onFetch, onFetchError, @@ -188,7 +187,12 @@ export default class GuildSubscriptions { guild: CombinedGuild, eventMappingParams: MultipleEventMappingParams, fetchFunc: (() => Promise) | (() => Promise) - ): [value: T[] | null, fetchError: unknown | null, events: EventEmitter>] { + ): [ + fetchRetryCallable: () => Promise, + value: T[] | null, + fetchError: unknown | null, + events: EventEmitter> + ] { const { newEventName, newEventArgsMap, updatedEventName, updatedEventArgsMap, @@ -197,7 +201,7 @@ export default class GuildSubscriptions { sortFunc } = eventMappingParams; - const isMountedRef = useRef(false); + const isMounted = ReactHelper.useIsMountedRef(); const [ fetchError, setFetchError ] = useState(null); const [ value, setValue ] = useState(null); @@ -209,7 +213,7 @@ export default class GuildSubscriptions { setValue(fetchValue); setFetchError(null); events.emit('fetch'); - }, []); + }, [ sortFunc ]); const onFetchError = useCallback((e: unknown) => { setFetchError(e); @@ -223,14 +227,14 @@ export default class GuildSubscriptions { return currentValue.concat(newElements).sort(sortFunc); }) events.emit('new', newElements); - }, []); + }, [ sortFunc ]); 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; @@ -238,7 +242,7 @@ export default class GuildSubscriptions { return currentValue.filter(element => !deletedIds.has(element.id)).sort(sortFunc); }); events.emit('removed', removedElements); - }, []); + }, [ sortFunc ]); const onConflict = useCallback((changes: Changes) => { setValue(currentValue => { @@ -251,41 +255,41 @@ export default class GuildSubscriptions { .sort(sortFunc); }); events.emit('conflict', changes); - }, []); + }, [ sortFunc ]); // 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): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; onNew(newEventArgsMap(...args)); - }, []) as (Connectable & Conflictable)[NE]; + }, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; const boundUpdateFunc = useCallback((...args: Arguments): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; onUpdated(updatedEventArgsMap(...args)); - }, []) as (Connectable & Conflictable)[UE]; + }, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; const boundRemovedFunc = useCallback((...args: Arguments): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; onRemoved(removedEventArgsMap(...args)); - }, []) as (Connectable & Conflictable)[RE]; + }, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; const boundConflictFunc = useCallback((...args: Arguments): void => { - if (!isMountedRef.current) return; + if (!isMounted.current) return; onConflict(conflictEventArgsMap(...args)); - }, []) as (Connectable & Conflictable)[CE]; + }, [ 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 ]); - GuildSubscriptions.useGuildSubscriptionEffect(isMountedRef, { + const [ fetchRetryCallable ] = GuildSubscriptions.useGuildSubscriptionEffect({ guild, onFetch, onFetchError, @@ -293,7 +297,253 @@ export default class GuildSubscriptions { unbindEventsFunc }, 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, + maxElements: number, + maxFetchElements: number, + fetchFunc: (() => Promise) | (() => Promise), + fetchAboveFunc: ((reference: T) => Promise), + fetchBelowFunc: ((reference: T) => Promise), + ): [ + fetchRetryCallable: () => Promise, + fetchAboveCallable: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>, + fetchBelowCallable: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>, + setScrollRatio: Dispatch>, + fetchResult: { hasMoreAbove: boolean, hasMoreBelow: boolean } | null, + value: T[] | null, + fetchError: unknown | null, + fetchAboveError: unknown | null, + fetchBelowError: unknown | null, + events: EventEmitter> + ] { + const { + newEventName, newEventArgsMap, + updatedEventName, updatedEventArgsMap, + removedEventName, removedEventArgsMap, + conflictEventName, conflictEventArgsMap, + sortFunc + } = eventMappingParams; + + const isMounted = ReactHelper.useIsMountedRef(); + + const [ value, setValue ] = useState(null); + + const [ fetchError, setFetchError ] = useState(null); + const [ fetchAboveError, setFetchAboveError ] = useState(null); + const [ fetchBelowError, setFetchBelowError ] = useState(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(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(elements: T[], counts: { fromTop: number, fromBottom: number }): T[] { + const { fromTop, fromBottom } = counts; + return elements.slice(fromTop, elements.length - fromBottom); + } + + const events = useMemo(() => new EventEmitter>(), []); + + 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) => { + 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): void => { + if (!isMounted.current) return; + onNew(newEventArgsMap(...args)); + }, [ onNew, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; + const boundUpdateFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + onUpdated(updatedEventArgsMap(...args)); + }, [ onUpdated, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE]; + const boundRemovedFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + onRemoved(removedEventArgsMap(...args)); + }, [ onRemoved, removedEventArgsMap ]) as (Connectable & Conflictable)[RE]; + const boundConflictFunc = useCallback((...args: Arguments): 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) { @@ -374,4 +624,33 @@ export default class GuildSubscriptions { sortFunc: Token.sortRecentCreatedFirst }, 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) => changes, + sortFunc: Message.sortOrder + }, + maxElements, maxFetchElements, + fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc + ) + } } diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 4d85f25..130a5d2 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; 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 { ShouldNeverHappenError } from "../../data-types"; import Util from '../../util'; @@ -261,4 +261,80 @@ export default class ReactHelper { return [ callable, text, shaking, result, errorMessage ]; } + + static useColumnReverseInfiniteScroll( + threshold: number, + loadMoreAbove: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>, + loadMoreBelow: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>, + setScrollRatio: Dispatch> + ): [ + updateCallable: (event: UIEvent) => void, + onLoadCallable: (params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => void, + loadAboveRetry: () => Promise, + loadBelowRetry: () => Promise + ] { + const isMounted = ReactHelper.useIsMountedRef(); + + const [ loadingAbove, setLoadingAbove ] = useState(false); + const [ loadingBelow, setLoadingBelow ] = useState(false); + + const [ hasMoreAbove, setHasMoreAbove ] = useState(false); + const [ hasMoreBelow, setHasMoreBelow ] = useState(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) => { + 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 ]; + } } diff --git a/src/client/webapp/message-ram-cache.ts b/src/client/webapp/message-ram-cache.ts index a56cf9d..bcfd476 100644 --- a/src/client/webapp/message-ram-cache.ts +++ b/src/client/webapp/message-ram-cache.ts @@ -1,6 +1,11 @@ import { Message, ShouldNeverHappenError } from "./data-types"; 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 { messages: Map; totalCharacters: number; @@ -87,7 +92,7 @@ export default class MessageRAMCache { if (!value) throw new ShouldNeverHappenError('unable to get message map'); value.lastUsed = new Date(); 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); if (result.length === 0) { return null; diff --git a/src/client/webapp/styles/scrollbars.scss b/src/client/webapp/styles/scrollbars.scss index a77cb28..441e607 100644 --- a/src/client/webapp/styles/scrollbars.scss +++ b/src/client/webapp/styles/scrollbars.scss @@ -3,12 +3,12 @@ /* channel feed custom 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, -.message-list-anchor::-webkit-scrollbar { +.message-list .infinite-scroll-scroll-base::-webkit-scrollbar { width: 16px; } #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 */ box-shadow: 0 0 12px 12px $background-secondary inset; border: 4px solid transparent; @@ -16,7 +16,7 @@ } #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 */ box-shadow: 0 0 12px 12px $background-tertiary inset; border: 4px solid transparent; @@ -24,14 +24,14 @@ } #channel-feed-content-wrapper::-webkit-scrollbar-button, -.message-list-anchor::-webkit-scrollbar-button { +.message-list .infinite-scroll-scroll-base::-webkit-scrollbar-button { display: none; } /* 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 */ -:not(:hover)::-webkit-scrollbar-thumb { +:not(.message-list .infinite-scroll-scroll-base):not(:hover)::-webkit-scrollbar-thumb { visibility: hidden; }