From 1e437c33aee5fe27c686527ab70fdbc93c9e03a5 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Thu, 30 Dec 2021 21:33:15 -0600 Subject: [PATCH] move GuildSubscriptions to just function exports --- .../displays/display-guild-invites.tsx | 8 +- .../displays/display-guild-overview.tsx | 6 +- .../lists/components/guild-list-element.tsx | 8 +- .../lists/components/member-element.tsx | 4 +- .../lists/components/message-element.tsx | 4 +- .../webapp/elements/lists/message-list.tsx | 4 +- .../elements/overlays/overlay-image.tsx | 4 +- .../elements/overlays/overlay-personalize.tsx | 4 +- .../webapp/elements/require/elements-util.tsx | 2 +- .../elements/require/guild-subscriptions.ts | 1242 +++++++++-------- src/client/webapp/elements/sections/guild.tsx | 10 +- 11 files changed, 653 insertions(+), 643 deletions(-) diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 14dbbe9..773f963 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -13,8 +13,8 @@ import { Duration } from 'moment'; import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; import Button, { ButtonColorType } from '../components/button'; -import GuildSubscriptions from '../require/guild-subscriptions'; import BaseElements from '../require/base-elements'; +import { useTokensSubscription, useGuildMetadataSubscription, useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; export interface GuildInvitesDisplayProps { @@ -25,14 +25,14 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi const url = 'https://localhost:3030'; // TODO: this will likely be a dropdown list at some point - const [ fetchRetryCallable, tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild); + const [ fetchRetryCallable, tokens, tokensError ] = useTokensSubscription(guild); - const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); + const [ guildMeta, guildMetaError ] = useGuildMetadataSubscription(guild); const [ expiresFromNow, setExpiresFromNow ] = useState(moment.duration(1, 'day')); const [ expiresFromNowText, setExpiresFromNowText ] = useState('1 day'); - const [ iconSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null); + const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null); useEffect(() => { if (expiresFromNowText === 'never') { diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 8253260..55d6a2b 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -10,7 +10,7 @@ import CombinedGuild from '../../guild-combined'; import Display from '../components/display'; import TextInput from '../components/input-text'; import ImageEditInput from '../components/input-image-edit'; -import GuildSubscriptions from '../require/guild-subscriptions'; +import { useGuildMetadataSubscription, useResourceSubscription } from '../require/guild-subscriptions'; export interface GuildOverviewDisplayProps { guild: CombinedGuild; @@ -18,8 +18,8 @@ export interface GuildOverviewDisplayProps { const GuildOverviewDisplay: FC = (props: GuildOverviewDisplayProps) => { const { guild } = props; - const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); - const [ iconResource, iconResourceError ] = GuildSubscriptions.useResourceSubscription(guild, guildMeta?.iconResourceId ?? null); + const [ guildMeta, guildMetaError ] = useGuildMetadataSubscription(guild); + const [ iconResource, iconResourceError ] = useResourceSubscription(guild, guildMeta?.iconResourceId ?? null); const [ savedName, setSavedName ] = useState(null); const [ savedIconBuff, setSavedIconBuff ] = useState(null); diff --git a/src/client/webapp/elements/lists/components/guild-list-element.tsx b/src/client/webapp/elements/lists/components/guild-list-element.tsx index c3d4567..36ef677 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -5,7 +5,7 @@ import ContextMenu from '../../contexts/components/context-menu'; import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic'; import BaseElements from '../../require/base-elements'; import { IAlignment } from '../../require/elements-util'; -import GuildSubscriptions from '../../require/guild-subscriptions'; +import { useGuildMetadataSubscription, useSelfMemberSubscription, useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions'; import { useContextClickContextMenu, useContextHover } from '../../require/react-helper'; export interface GuildListElementProps { @@ -22,9 +22,9 @@ const GuildListElement: FC = (props: GuildListElementProp // TODO: state higher up // TODO: handle metadata error - const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); - const [ selfMember ] = GuildSubscriptions.useSelfMemberSubscription(guild); - const [ iconSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null); + const [ guildMeta, guildMetaError ] = useGuildMetadataSubscription(guild); + const [ selfMember ] = useSelfMemberSubscription(guild); + const [ iconSrc ] = useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null); const [ contextHover, mouseEnterCallable, mouseLeaveCallable ] = useContextHover(() => { if (!guildMeta) return null; diff --git a/src/client/webapp/elements/lists/components/member-element.tsx b/src/client/webapp/elements/lists/components/member-element.tsx index 12bb63c..7bbd8f4 100644 --- a/src/client/webapp/elements/lists/components/member-element.tsx +++ b/src/client/webapp/elements/lists/components/member-element.tsx @@ -1,7 +1,7 @@ import React, { FC, useMemo } from 'react'; import { Member } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; -import GuildSubscriptions from '../../require/guild-subscriptions'; +import { useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions'; export interface DummyMember { id: 'dummy'; @@ -19,7 +19,7 @@ export interface MemberProps { const MemberElement: FC = (props: MemberProps) => { const { guild, member } = props; - const [ avatarSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, member.avatarResourceId); + const [ avatarSrc ] = useSoftImageSrcResourceSubscription(guild, member.avatarResourceId); const nameStyle = useMemo(() => member.roleColor ? { color: member.roleColor } : {}, [ member.roleColor ]); diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index 74a145e..0773509 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -5,7 +5,7 @@ import CombinedGuild from '../../../guild-combined'; import ImageContextMenu from '../../contexts/context-menu-image'; import ImageOverlay from '../../overlays/overlay-image'; import ElementsUtil, { IAlignment } from '../../require/elements-util'; -import GuildSubscriptions from '../../require/guild-subscriptions'; +import { useSoftImageSrcResourceSubscription } from '../../require/guild-subscriptions'; import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper'; interface ResourceElementProps { @@ -53,7 +53,7 @@ const PreviewImageElement: FC = (props: PreviewImageEl const { guild, previewWidth, previewHeight, resourcePreviewId, resourceId, resourceName, setOverlay } = props; // TODO: Handle resourceError - const [ previewImgSrc, previewResource, previewResourceError ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, resourcePreviewId); + const [ previewImgSrc, previewResource, previewResourceError ] = useSoftImageSrcResourceSubscription(guild, resourcePreviewId); const [ contextMenu, onContextMenu ] = useContextClickContextMenu((alignment: IAlignment, relativeToPos: { x: number, y: number }, close: () => void) => { if (!previewResource) return null; diff --git a/src/client/webapp/elements/lists/message-list.tsx b/src/client/webapp/elements/lists/message-list.tsx index 47cc7c5..46db3b9 100644 --- a/src/client/webapp/elements/lists/message-list.tsx +++ b/src/client/webapp/elements/lists/message-list.tsx @@ -7,8 +7,8 @@ import React, { Dispatch, FC, ReactNode, SetStateAction, useMemo } from 'react'; import { Channel, Message } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import MessageElement from './components/message-element'; -import GuildSubscriptions from '../require/guild-subscriptions'; import InfiniteScroll from '../components/infinite-scroll'; +import { useMessagesScrollingSubscription } from '../require/guild-subscriptions'; interface MessageListProps { guild: CombinedGuild; @@ -29,7 +29,7 @@ const MessageList: FC = (props: MessageListProps) => { messagesFetchError, messagesFetchAboveError, messagesFetchBelowError - ] = GuildSubscriptions.useMessagesScrollingSubscription(guild, channel); + ] = useMessagesScrollingSubscription(guild, channel); const messageElements = useMemo(() => { const result = []; diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx index d71d170..1fadb39 100644 --- a/src/client/webapp/elements/overlays/overlay-image.tsx +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -8,9 +8,9 @@ import CombinedGuild from '../../guild-combined'; import ElementsUtil, { IAlignment } from '../require/elements-util'; import DownloadButton from '../components/button-download'; import { useContextClickContextMenu } from '../require/react-helper'; -import GuildSubscriptions from '../require/guild-subscriptions'; import ImageContextMenu from '../contexts/context-menu-image'; import Overlay from '../components/overlay'; +import { useSoftImageSrcResourceSubscription } from '../require/guild-subscriptions'; export interface ImageOverlayProps { guild: CombinedGuild @@ -24,7 +24,7 @@ const ImageOverlay: FC = (props: ImageOverlayProps) => { const rootRef = useRef(null); - const [ imgSrc, resource, resourceError ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, resourceId); + const [ imgSrc, resource, resourceError ] = useSoftImageSrcResourceSubscription(guild, resourceId); const [ contextMenu, onContextMenu ] = useContextClickContextMenu((alignment: IAlignment, relativeToPos: { x: number, y: number }, close: () => void) => { if (!resource) return null; diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 8df393e..0aca1cd 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -10,10 +10,10 @@ import CombinedGuild from '../../guild-combined'; import ImageEditInput from '../components/input-image-edit'; import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; -import GuildSubscriptions from '../require/guild-subscriptions'; import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; +import { useResourceSubscription } from '../require/guild-subscriptions'; export interface PersonalizeOverlayProps { document: Document; @@ -26,7 +26,7 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const rootRef = useRef(null); - const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, selfMember.avatarResourceId) + const [ avatarResource, avatarResourceError ] = useResourceSubscription(guild, selfMember.avatarResourceId) const displayNameInputRef = createRef(); diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index 5e9c39a..f7d0d10 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -81,7 +81,7 @@ export default class ElementsUtil { } } - // Avoid this function. Use GuildSubscriptions.useSoftImgSrcResourceSubscription instead + // Avoid this function. Use useSoftImgSrcResourceSubscription instead static async getImageSrcFromResourceFailSoftly(guild: CombinedGuild, resourceId: string | null): Promise { if (resourceId === null) { return './img/loading.svg'; diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index 58b0877..3846fa1 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -70,624 +70,634 @@ interface MultipleEventMappingParams< } export default class GuildSubscriptions { - private static useGuildSubscriptionEffect( - subscriptionParams: EffectParams, - fetchFunc: (() => Promise) | (() => Promise) - ): [ fetchRetryCallable: () => Promise ] { - const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; + - const isMounted = 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(() => { - // Bind guild events to make sure we have the most up to date information - guild.on('connect', fetchManagerFunc); - bindEventsFunc(); + - // Fetch the data once - fetchManagerFunc(); - - return () => { - // Unbind the events so that we don't have any memory leaks - guild.off('connect', fetchManagerFunc); - unbindEventsFunc(); - } - }, [ fetchManagerFunc ]); - - return [ fetchManagerFunc ]; - } - - private static useSingleGuildSubscription( - guild: CombinedGuild, - eventMappingParams: SingleEventMappingParams, - fetchFunc: (() => Promise) | (() => Promise) - ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { - const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; - - const isMounted = useIsMountedRef(); - - const [ fetchError, setFetchError ] = useState(null); - const [ value, setValue ] = useState(null); - - const events = useMemo(() => new EventEmitter(), []); - - const onFetch = useCallback((fetchValue: T | null) => { - setValue(fetchValue); - setFetchError(null); - events.emit('fetch'); - }, []); - - const onFetchError = useCallback((e: unknown) => { - setFetchError(e); - setValue(null); - events.emit('fetch-error'); - }, []); - - const onUpdated = useCallback((updateValue: T) => { - setValue(updateValue); - events.emit('updated'); - }, []); - - const onConflict = useCallback((conflictValue: T) => { - setValue(conflictValue); - events.emit('conflict'); - }, []); - - // 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 (!isMounted.current) return; - const value = updatedEventArgsMap(...args); - onUpdated(value); - }, []) as (Connectable & Conflictable)[UE]; - const boundConflictFunc = useCallback((...args: Arguments): void => { - if (!isMounted.current) return; - const value = conflictEventArgsMap(...args); - onConflict(value); - }, []) as (Connectable & Conflictable)[CE]; - - const bindEventsFunc = useCallback(() => { - guild.on(updatedEventName, boundUpdateFunc); - guild.on(conflictEventName, boundConflictFunc); - }, []); - const unbindEventsFunc = useCallback(() => { - guild.off(updatedEventName, boundUpdateFunc); - guild.off(conflictEventName, boundConflictFunc); - }, []); - - GuildSubscriptions.useGuildSubscriptionEffect({ - guild, - onFetch, - onFetchError, - bindEventsFunc, - unbindEventsFunc - }, fetchFunc); - - return [ value, fetchError, events ]; - } - - private static useMultipleGuildSubscription< - T extends { id: string }, - NE extends keyof Connectable, - UE extends keyof Connectable, - RE extends keyof Connectable, - CE extends keyof Conflictable - >( - guild: CombinedGuild, - eventMappingParams: MultipleEventMappingParams, - fetchFunc: (() => Promise) | (() => Promise) - ): [ - fetchRetryCallable: () => Promise, - value: T[] | null, - fetchError: unknown | null, - events: EventEmitter> - ] { - const { - newEventName, newEventArgsMap, - updatedEventName, updatedEventArgsMap, - removedEventName, removedEventArgsMap, - conflictEventName, conflictEventArgsMap, - sortFunc - } = eventMappingParams; - - const isMounted = useIsMountedRef(); - - const [ fetchError, setFetchError ] = useState(null); - const [ value, setValue ] = useState(null); - - const events = useMemo(() => new EventEmitter>(), []); - - const onFetch = useCallback((fetchValue: T[] | null) => { - if (fetchValue) fetchValue.sort(sortFunc); - setValue(fetchValue); - setFetchError(null); - events.emit('fetch'); - }, [ sortFunc ]); - - 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; - 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; - 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)); - return 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); - }); - 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 (!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, 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 = 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) { - const fetchMetadataFunc = useCallback(async () => { - //LOG.silly('fetching metadata for subscription'); - return await guild.fetchMetadata(); - }, [ guild ]); - return GuildSubscriptions.useSingleGuildSubscription(guild, { - updatedEventName: 'update-metadata', - updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, - conflictEventName: 'conflict-metadata', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta - }, fetchMetadataFunc); - } - - static useResourceSubscription(guild: CombinedGuild, resourceId: string | null) { - const fetchResourceFunc = useCallback(async () => { - //LOG.silly('fetching resource for subscription (resourceId: ' + resourceId + ')'); - if (resourceId === null) return null; - return await guild.fetchResource(resourceId); - }, [ guild, resourceId ]); - return GuildSubscriptions.useSingleGuildSubscription(guild, { - updatedEventName: 'update-resource', - updatedEventArgsMap: (resource: Resource) => resource, - conflictEventName: 'conflict-resource', - conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource - }, fetchResourceFunc); - } - - static useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null): [ imgSrc: string, resource: Resource | null, fetchError: unknown | null ] { - const [ resource, fetchError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId); - - const [ imgSrc ] = useOneTimeAsyncAction( - async () => { - if (fetchError) return './img/error.png'; - if (!resource) return './img/loading.svg'; - return await ElementsUtil.getImageSrcFromBufferFailSoftly(resource.data); - }, - './img/loading.svg', - [ resource, fetchError ] - ); - - return [ imgSrc, resource, fetchError ]; - } - - static useChannelsSubscription(guild: CombinedGuild) { - const fetchChannelsFunc = useCallback(async () => { - return await guild.fetchChannels(); - }, [ guild ]); - return GuildSubscriptions.useMultipleGuildSubscription(guild, { - newEventName: 'new-channels', - newEventArgsMap: (channels: Channel[]) => channels, - updatedEventName: 'update-channels', - updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels, - removedEventName: 'remove-channels', - removedEventArgsMap: (removedChannels: Channel[]) => removedChannels, - conflictEventName: 'conflict-channels', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, - sortFunc: Channel.sortByIndex - }, fetchChannelsFunc); - } - - static useMembersSubscription(guild: CombinedGuild) { - const fetchMembersFunc = useCallback(async () => { - return await guild.fetchMembers(); - }, [ guild ]); - return GuildSubscriptions.useMultipleGuildSubscription(guild, { - newEventName: 'new-members', - newEventArgsMap: (members: Member[]) => members, - updatedEventName: 'update-members', - updatedEventArgsMap: (updatedMembers: Member[]) => updatedMembers, - removedEventName: 'remove-members', - removedEventArgsMap: (removedMembers: Member[]) => removedMembers, - conflictEventName: 'conflict-members', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, - sortFunc: Member.sortForList - }, fetchMembersFunc); - } - - static useSelfMemberSubscription(guild: CombinedGuild): [ selfMember: Member | null ] { - const [ fetchRetryCallable, members, fetchError ] = GuildSubscriptions.useMembersSubscription(guild); - - // TODO: Show an error if we can't fetch and allow retry - - const selfMember = useMemo(() => { - if (members) { - const member = members.find(m => m.id === guild.memberId); - if (!member) { - LOG.warn('Unable to find self in members'); - return null; - } - return member; - } - return null; - }, [ guild.memberId, members ]); - - return [ selfMember ]; - } - - static useTokensSubscription(guild: CombinedGuild) { - const fetchTokensFunc = useCallback(async () => { - //LOG.silly('fetching tokens for subscription'); - return await guild.fetchTokens(); - }, [ guild ]); - return GuildSubscriptions.useMultipleGuildSubscription(guild, { - newEventName: 'new-tokens', - newEventArgsMap: (tokens: Token[]) => tokens, - updatedEventName: 'update-tokens', - updatedEventArgsMap: (updatedTokens: Token[]) => updatedTokens, - removedEventName: 'remove-tokens', - removedEventArgsMap: (removedTokens: Token[]) => removedTokens, - conflictEventName: 'conflict-tokens', - conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, - 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 - ) - } + +} + +function useGuildSubscriptionEffect( + subscriptionParams: EffectParams, + fetchFunc: (() => Promise) | (() => Promise) +): [ fetchRetryCallable: () => Promise ] { + const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; + + const isMounted = 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(() => { + // Bind guild events to make sure we have the most up to date information + guild.on('connect', fetchManagerFunc); + bindEventsFunc(); + + // Fetch the data once + fetchManagerFunc(); + + return () => { + // Unbind the events so that we don't have any memory leaks + guild.off('connect', fetchManagerFunc); + unbindEventsFunc(); + } + }, [ fetchManagerFunc ]); + + return [ fetchManagerFunc ]; +} + +function useSingleGuildSubscription( + guild: CombinedGuild, + eventMappingParams: SingleEventMappingParams, + fetchFunc: (() => Promise) | (() => Promise) +): [value: T | null, fetchError: unknown | null, events: EventEmitter] { + const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; + + const isMounted = useIsMountedRef(); + + const [ fetchError, setFetchError ] = useState(null); + const [ value, setValue ] = useState(null); + + const events = useMemo(() => new EventEmitter(), []); + + const onFetch = useCallback((fetchValue: T | null) => { + setValue(fetchValue); + setFetchError(null); + events.emit('fetch'); + }, []); + + const onFetchError = useCallback((e: unknown) => { + setFetchError(e); + setValue(null); + events.emit('fetch-error'); + }, []); + + const onUpdated = useCallback((updateValue: T) => { + setValue(updateValue); + events.emit('updated'); + }, []); + + const onConflict = useCallback((conflictValue: T) => { + setValue(conflictValue); + events.emit('conflict'); + }, []); + + // 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 (!isMounted.current) return; + const value = updatedEventArgsMap(...args); + onUpdated(value); + }, []) as (Connectable & Conflictable)[UE]; + const boundConflictFunc = useCallback((...args: Arguments): void => { + if (!isMounted.current) return; + const value = conflictEventArgsMap(...args); + onConflict(value); + }, []) as (Connectable & Conflictable)[CE]; + + const bindEventsFunc = useCallback(() => { + guild.on(updatedEventName, boundUpdateFunc); + guild.on(conflictEventName, boundConflictFunc); + }, []); + const unbindEventsFunc = useCallback(() => { + guild.off(updatedEventName, boundUpdateFunc); + guild.off(conflictEventName, boundConflictFunc); + }, []); + + useGuildSubscriptionEffect({ + guild, + onFetch, + onFetchError, + bindEventsFunc, + unbindEventsFunc + }, fetchFunc); + + return [ value, fetchError, events ]; +} + +function useMultipleGuildSubscription< + T extends { id: string }, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable +>( + guild: CombinedGuild, + eventMappingParams: MultipleEventMappingParams, + fetchFunc: (() => Promise) | (() => Promise) +): [ + fetchRetryCallable: () => Promise, + value: T[] | null, + fetchError: unknown | null, + events: EventEmitter> +] { + const { + newEventName, newEventArgsMap, + updatedEventName, updatedEventArgsMap, + removedEventName, removedEventArgsMap, + conflictEventName, conflictEventArgsMap, + sortFunc + } = eventMappingParams; + + const isMounted = useIsMountedRef(); + + const [ fetchError, setFetchError ] = useState(null); + const [ value, setValue ] = useState(null); + + const events = useMemo(() => new EventEmitter>(), []); + + const onFetch = useCallback((fetchValue: T[] | null) => { + if (fetchValue) fetchValue.sort(sortFunc); + setValue(fetchValue); + setFetchError(null); + events.emit('fetch'); + }, [ sortFunc ]); + + 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; + 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; + 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)); + return 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); + }); + 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 (!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 ] = useGuildSubscriptionEffect({ + guild, + onFetch, + onFetchError, + bindEventsFunc, + unbindEventsFunc + }, fetchFunc); + + return [ fetchRetryCallable, value, fetchError, events ]; +} + +function 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 = 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 ] = useGuildSubscriptionEffect({ + guild, + onFetch, + onFetchError, + bindEventsFunc, + unbindEventsFunc + }, fetchFunc); + + return [ + fetchRetryCallable, + fetchAboveCallable, + fetchBelowCallable, + setScrollRatio, + fetchResult, + value, + fetchError, + fetchAboveError, + fetchBelowError, + events + ]; +} + +export function useGuildMetadataSubscription(guild: CombinedGuild) { + const fetchMetadataFunc = useCallback(async () => { + //LOG.silly('fetching metadata for subscription'); + return await guild.fetchMetadata(); + }, [ guild ]); + return useSingleGuildSubscription(guild, { + updatedEventName: 'update-metadata', + updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, + conflictEventName: 'conflict-metadata', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta + }, fetchMetadataFunc); +} + +export function useResourceSubscription(guild: CombinedGuild, resourceId: string | null) { + const fetchResourceFunc = useCallback(async () => { + //LOG.silly('fetching resource for subscription (resourceId: ' + resourceId + ')'); + if (resourceId === null) return null; + return await guild.fetchResource(resourceId); + }, [ guild, resourceId ]); + return useSingleGuildSubscription(guild, { + updatedEventName: 'update-resource', + updatedEventArgsMap: (resource: Resource) => resource, + conflictEventName: 'conflict-resource', + conflictEventArgsMap: (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => newResource + }, fetchResourceFunc); +} + +export function useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null): [ imgSrc: string, resource: Resource | null, fetchError: unknown | null ] { + const [ resource, fetchError ] = useResourceSubscription(guild, resourceId); + + const [ imgSrc ] = useOneTimeAsyncAction( + async () => { + if (fetchError) return './img/error.png'; + if (!resource) return './img/loading.svg'; + return await ElementsUtil.getImageSrcFromBufferFailSoftly(resource.data); + }, + './img/loading.svg', + [ resource, fetchError ] + ); + + return [ imgSrc, resource, fetchError ]; +} + +export function useChannelsSubscription(guild: CombinedGuild) { + const fetchChannelsFunc = useCallback(async () => { + return await guild.fetchChannels(); + }, [ guild ]); + return useMultipleGuildSubscription(guild, { + newEventName: 'new-channels', + newEventArgsMap: (channels: Channel[]) => channels, + updatedEventName: 'update-channels', + updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels, + removedEventName: 'remove-channels', + removedEventArgsMap: (removedChannels: Channel[]) => removedChannels, + conflictEventName: 'conflict-channels', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + sortFunc: Channel.sortByIndex + }, fetchChannelsFunc); +} + +export function useMembersSubscription(guild: CombinedGuild) { + const fetchMembersFunc = useCallback(async () => { + return await guild.fetchMembers(); + }, [ guild ]); + return useMultipleGuildSubscription(guild, { + newEventName: 'new-members', + newEventArgsMap: (members: Member[]) => members, + updatedEventName: 'update-members', + updatedEventArgsMap: (updatedMembers: Member[]) => updatedMembers, + removedEventName: 'remove-members', + removedEventArgsMap: (removedMembers: Member[]) => removedMembers, + conflictEventName: 'conflict-members', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + sortFunc: Member.sortForList + }, fetchMembersFunc); +} + +export function useSelfMemberSubscription(guild: CombinedGuild): [ selfMember: Member | null ] { + const [ fetchRetryCallable, members, fetchError ] = useMembersSubscription(guild); + + // TODO: Show an error if we can't fetch and allow retry + + const selfMember = useMemo(() => { + if (members) { + const member = members.find(m => m.id === guild.memberId); + if (!member) { + LOG.warn('Unable to find self in members'); + return null; + } + return member; + } + return null; + }, [ guild.memberId, members ]); + + return [ selfMember ]; +} + +export function useTokensSubscription(guild: CombinedGuild) { + const fetchTokensFunc = useCallback(async () => { + //LOG.silly('fetching tokens for subscription'); + return await guild.fetchTokens(); + }, [ guild ]); + return useMultipleGuildSubscription(guild, { + newEventName: 'new-tokens', + newEventArgsMap: (tokens: Token[]) => tokens, + updatedEventName: 'update-tokens', + updatedEventArgsMap: (updatedTokens: Token[]) => updatedTokens, + removedEventName: 'remove-tokens', + removedEventArgsMap: (removedTokens: Token[]) => removedTokens, + conflictEventName: 'conflict-tokens', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + sortFunc: Token.sortRecentCreatedFirst + }, fetchTokensFunc); +} + +export function 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 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/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index 43a817f..8cf45eb 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -4,7 +4,7 @@ import CombinedGuild from '../../guild-combined'; import ChannelList from '../lists/channel-list'; import MemberList from '../lists/member-list'; import MessageList from '../lists/message-list'; -import GuildSubscriptions from '../require/guild-subscriptions'; +import { useSelfMemberSubscription, useGuildMetadataSubscription, useMembersSubscription, useChannelsSubscription } from '../require/guild-subscriptions'; import ChannelTitle from './channel-title'; import ConnectionInfo from './connection-info'; import GuildTitle from './guild-title'; @@ -23,10 +23,10 @@ const GuildElement: FC = (props: GuildElementProps) => { // TODO: React set hasMessagesAbove and hasMessagesBelow when re-verified? // TODO: React jump messages to bottom when the current user sent a message - const [ selfMember ] = GuildSubscriptions.useSelfMemberSubscription(guild); - const [ guildMeta, guildMetaFetchError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); - const [ membersRetry, members, membersFetchError ] = GuildSubscriptions.useMembersSubscription(guild); - const [ channelsRetry, channels, channelsFetchError ] = GuildSubscriptions.useChannelsSubscription(guild); + const [ selfMember ] = useSelfMemberSubscription(guild); + const [ guildMeta, guildMetaFetchError ] = useGuildMetadataSubscription(guild); + const [ membersRetry, members, membersFetchError ] = useMembersSubscription(guild); + const [ channelsRetry, channels, channelsFetchError ] = useChannelsSubscription(guild); const [ activeChannel, setActiveChannel ] = useState(null);