From eac2c42e534a8ae0001fbde1fb9530755ba6be3a Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 6 Feb 2022 17:22:49 -0600 Subject: [PATCH] split up atoms-2 helper functions into different file --- .../webapp/elements/components/token-row.tsx | 3 +- .../contexts/context-menu-guild-title.tsx | 3 +- .../displays/display-guild-invites.tsx | 3 +- .../displays/display-guild-overview.tsx | 3 +- .../webapp/elements/lists/channel-list.tsx | 3 +- .../lists/components/channel-element.tsx | 5 +- .../lists/components/guild-list-element.tsx | 3 +- .../lists/components/message-element.tsx | 3 +- .../webapp/elements/lists/member-list.tsx | 3 +- .../webapp/elements/lists/message-list.tsx | 26 +- .../overlays/overlay-guild-settings.tsx | 3 +- .../elements/overlays/overlay-image.tsx | 3 +- .../elements/overlays/overlay-personalize.tsx | 3 +- src/client/webapp/elements/require/atoms-2.ts | 736 +----------------- .../webapp/elements/require/atoms-funcs.ts | 536 +++++++++++++ .../webapp/elements/require/base-elements.tsx | 2 +- .../elements/require/guild-subscriptions.ts | 1 - .../webapp/elements/require/loadables.ts | 170 ++++ .../elements/sections/connection-info.tsx | 3 +- .../webapp/elements/sections/guild-title.tsx | 3 +- src/client/webapp/elements/sections/guild.tsx | 11 +- 21 files changed, 807 insertions(+), 719 deletions(-) create mode 100644 src/client/webapp/elements/require/atoms-funcs.ts create mode 100644 src/client/webapp/elements/require/loadables.ts diff --git a/src/client/webapp/elements/components/token-row.tsx b/src/client/webapp/elements/components/token-row.tsx index 79513c5..1128bbb 100644 --- a/src/client/webapp/elements/components/token-row.tsx +++ b/src/client/webapp/elements/components/token-row.tsx @@ -5,8 +5,9 @@ import { Member, Token } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import Util from '../../util'; import { IAddGuildData } from '../overlays/overlay-add-guild'; -import { guildMetaState, guildResourceSoftImgSrcState, isLoaded, useRecoilValueSoftImgSrc } from '../require/atoms-2'; +import { guildMetaState, guildResourceSoftImgSrcState, useRecoilValueSoftImgSrc } from '../require/atoms-2'; import BaseElements from '../require/base-elements'; +import { isLoaded } from '../require/loadables'; import { useAsyncVoidCallback, useDownloadButton, useOneTimeAsyncAction } from '../require/react-helper'; import Button, { ButtonColorType } from './button'; diff --git a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx index bf5ecd8..2c3e566 100644 --- a/src/client/webapp/elements/contexts/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/contexts/context-menu-guild-title.tsx @@ -9,7 +9,8 @@ import ChannelOverlay from '../overlays/overlay-channel'; import GuildSettingsOverlay from '../overlays/overlay-guild-settings'; import BaseElements from '../require/base-elements'; import ContextMenu from './components/context-menu'; -import { currGuildSelfMemberState, currGuildState, isLoaded, overlayState } from '../require/atoms-2'; +import { currGuildSelfMemberState, currGuildState, overlayState } from '../require/atoms-2'; +import { isLoaded } from '../require/loadables'; export interface GuildTitleContextMenuProps { close: () => void; diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 7b0824a..006c721 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -15,7 +15,8 @@ import Button from '../components/button'; import TokenRow from '../components/token-row'; import CombinedGuild from '../../guild-combined'; import { useRecoilValue } from 'recoil'; -import { guildMetaState, guildResourceSoftImgSrcState, guildTokensState, isFailed, isLoaded, useRecoilValueSoftImgSrc } from '../require/atoms-2'; +import { guildMetaState, guildResourceSoftImgSrcState, guildTokensState, useRecoilValueSoftImgSrc } from '../require/atoms-2'; +import { isFailed, isLoaded } from '../require/loadables'; export interface GuildInvitesDisplayProps { guild: CombinedGuild; diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 7c7c5b2..4bca70f 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -10,8 +10,9 @@ import Display from '../components/display'; import TextInput from '../components/input-text'; import ImageEditInput from '../components/input-image-edit'; import CombinedGuild from '../../guild-combined'; -import { guildMetaState, guildResourceState, isFailed, isLoaded } from '../require/atoms-2'; +import { guildMetaState, guildResourceState } from '../require/atoms-2'; import { useRecoilValue } from 'recoil'; +import { isFailed, isLoaded } from '../require/loadables'; interface GuildOverviewDisplayProps { guild: CombinedGuild; diff --git a/src/client/webapp/elements/lists/channel-list.tsx b/src/client/webapp/elements/lists/channel-list.tsx index a51f53c..5c28ffc 100644 --- a/src/client/webapp/elements/lists/channel-list.tsx +++ b/src/client/webapp/elements/lists/channel-list.tsx @@ -1,7 +1,8 @@ import React, { FC, useMemo } from 'react' import { useRecoilValue } from 'recoil'; import { Channel } from '../../data-types'; -import { currGuildChannelsState, currGuildSelfMemberState, currGuildState, isFailed, isLoaded } from '../require/atoms-2'; +import { currGuildChannelsState, currGuildSelfMemberState, currGuildState } from '../require/atoms-2'; +import { isFailed, isLoaded } from '../require/loadables'; import ChannelElement from './components/channel-element'; const ChannelList: FC = () => { diff --git a/src/client/webapp/elements/lists/components/channel-element.tsx b/src/client/webapp/elements/lists/components/channel-element.tsx index 71ba572..3fde74c 100644 --- a/src/client/webapp/elements/lists/components/channel-element.tsx +++ b/src/client/webapp/elements/lists/components/channel-element.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { Dispatch, FC, MouseEvent, ReactNode, SetStateAction, useCallback, useRef } from 'react' +import React, { FC, MouseEvent, ReactNode, useCallback, useRef } from 'react' import { Channel } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; import ChannelOverlay from '../../overlays/overlay-channel'; @@ -11,7 +11,8 @@ import BaseElements from '../../require/base-elements'; import { useContextHover } from '../../require/react-helper'; import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { currGuildActiveChannelState, guildActiveChannelIdState, isLoaded, overlayState } from '../../require/atoms-2'; +import { currGuildActiveChannelState, guildActiveChannelIdState, overlayState } from '../../require/atoms-2'; +import { isLoaded } from '../../require/loadables'; export interface ChannelElementProps { guild: CombinedGuild; 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 40720e7..7ebe1ae 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -3,9 +3,10 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import CombinedGuild from '../../../guild-combined'; import ContextMenu from '../../contexts/components/context-menu'; import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic'; -import { currGuildIdState, guildMetaState, guildResourceSoftImgSrcState, guildSelfMemberState, guildsManagerState, isLoaded, useRecoilValueSoftImgSrc } from '../../require/atoms-2'; +import { currGuildIdState, guildMetaState, guildResourceSoftImgSrcState, guildSelfMemberState, guildsManagerState, useRecoilValueSoftImgSrc } from '../../require/atoms-2'; import BaseElements from '../../require/base-elements'; import { IAlignment } from '../../require/elements-util'; +import { isLoaded } from '../../require/loadables'; import { useContextClickContextMenu, useContextHover } from '../../require/react-helper'; export interface GuildListElementProps { diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index 7b28579..9a05b43 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -5,8 +5,9 @@ import { Member, Message } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; import ImageContextMenu from '../../contexts/context-menu-image'; import ImageOverlay from '../../overlays/overlay-image'; -import { guildResourceSoftImgSrcState, guildResourceState, isLoaded, overlayState, useRecoilValueSoftImgSrc } from '../../require/atoms-2'; +import { guildResourceSoftImgSrcState, guildResourceState, overlayState, useRecoilValueSoftImgSrc } from '../../require/atoms-2'; import ElementsUtil, { IAlignment } from '../../require/elements-util'; +import { isLoaded } from '../../require/loadables'; import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper'; interface ResourceElementProps { diff --git a/src/client/webapp/elements/lists/member-list.tsx b/src/client/webapp/elements/lists/member-list.tsx index e682e20..4448481 100644 --- a/src/client/webapp/elements/lists/member-list.tsx +++ b/src/client/webapp/elements/lists/member-list.tsx @@ -7,7 +7,8 @@ import React, { FC, useMemo } from 'react'; import { Member } from '../../data-types'; import MemberElement from './components/member-element'; import { useRecoilValue } from 'recoil'; -import { currGuildMembersState, currGuildState, isFailed, isPended, isUnload } from '../require/atoms-2'; +import { currGuildMembersState, currGuildState } from '../require/atoms-2'; +import { isFailed, isPended, isUnload } from '../require/loadables'; const MemberList: FC = () => { const guild = useRecoilValue(currGuildState); diff --git a/src/client/webapp/elements/lists/message-list.tsx b/src/client/webapp/elements/lists/message-list.tsx index c8cb071..641a040 100644 --- a/src/client/webapp/elements/lists/message-list.tsx +++ b/src/client/webapp/elements/lists/message-list.tsx @@ -1,9 +1,17 @@ -import React, { Dispatch, FC, ReactNode, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'; +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, { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'; import { Channel, Message } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import MessageElement from './components/message-element'; import InfiniteScroll from '../components/infinite-scroll'; import { useMessagesScrollingSubscription } from '../require/guild-subscriptions'; +import { currGuildActiveChannelMessagesState } from '../require/atoms-2'; +import { useRecoilValue } from 'recoil'; +import { isFailed, isLoaded, isPended, isUnload } from '../require/loadables'; interface MessageListProps { guild: CombinedGuild; @@ -19,9 +27,23 @@ const MessageList: FC = (props: MessageListProps) => { const scrollToBottomFunc = useCallback(() => { if (!infiniteScrollElementRef.current) return; - infiniteScrollElementRef.current.scrollTop = 0; // Keep in mind that this is reversed for flex-flow: column-reverse + infiniteScrollElementRef.current.scrollTop = 0; // Keep in mind that this is reversed since we are in flex-flow: column-reverse }, []); + const recoilMessages = useRecoilValue(currGuildActiveChannelMessagesState); + useEffect(() => { + if (isUnload(recoilMessages)) { + LOG.debug('recoilMessages unloaded'); + } else if (isPended(recoilMessages)) { + LOG.debug('recoilMessages pended'); + } else if (isLoaded(recoilMessages)) { + LOG.debug('recoilMessages loaded. length: ' + recoilMessages.value.length); + } else if (isFailed(recoilMessages)) { + LOG.debug('recoilMessages failed'); + } + // LOG.debug('recoilMessages update', { recoilMessagesLength: recoilMessages.value?.length ?? 'unloaded' }); + }, [ recoilMessages ]); + // TODO: Store state higher up const [ fetchRetryCallable, diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index a360aab..ecbee60 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -4,8 +4,9 @@ import GuildInvitesDisplay from "../displays/display-guild-invites"; import GuildOverviewDisplay from "../displays/display-guild-overview"; import Overlay from '../components/overlay'; import CombinedGuild from "../../guild-combined"; -import { guildMetaState, isLoaded } from "../require/atoms-2"; +import { guildMetaState } from "../require/atoms-2"; import { useRecoilValue } from "recoil"; +import { isLoaded } from "../require/loadables"; interface GuildSettingsOverlayProps { guild: CombinedGuild; diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx index 2669ee5..57d3508 100644 --- a/src/client/webapp/elements/overlays/overlay-image.tsx +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -11,7 +11,8 @@ import { useContextClickContextMenu } from '../require/react-helper'; import ImageContextMenu from '../contexts/context-menu-image'; import Overlay from '../components/overlay'; import { useRecoilValue } from 'recoil'; -import { guildResourceSoftImgSrcState, guildResourceState, isFailed, isLoaded, useRecoilValueSoftImgSrc } from '../require/atoms-2'; +import { guildResourceSoftImgSrcState, guildResourceState, useRecoilValueSoftImgSrc } from '../require/atoms-2'; +import { isFailed, isLoaded } from '../require/loadables'; export interface ImageOverlayProps { guild: CombinedGuild; diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index c0c6beb..f7fbd64 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -14,7 +14,8 @@ import Overlay from '../components/overlay'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; -import { guildResourceState, isFailed, isLoaded, overlayState } from '../require/atoms-2'; +import { guildResourceState, overlayState } from '../require/atoms-2'; +import { isFailed, isLoaded } from '../require/loadables'; interface PersonalizeOverlayProps { guild: CombinedGuild; diff --git a/src/client/webapp/elements/require/atoms-2.ts b/src/client/webapp/elements/require/atoms-2.ts index 4d93781..6040213 100644 --- a/src/client/webapp/elements/require/atoms-2.ts +++ b/src/client/webapp/elements/require/atoms-2.ts @@ -4,188 +4,14 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import { ReactNode, useEffect } from "react"; -import { atom, AtomEffect, atomFamily, GetRecoilValue, Loadable, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useRecoilValueLoadable, useSetRecoilState } from "recoil"; +import { atom, atomFamily, GetRecoilValue, RecoilState, RecoilValue, RecoilValueReadOnly, selector, selectorFamily, useSetRecoilState } from "recoil"; import { Changes, Channel, GuildMetadata, Member, Message, Resource, ShouldNeverHappenError, Token } from "../../data-types"; import CombinedGuild from "../../guild-combined"; import GuildsManager from "../../guilds-manager"; -import { Conflictable, Connectable } from '../../guild-types'; import ElementsUtil from './elements-util'; import Globals from '../../globals'; - -// General typescript type that infers the arguments of a function -type Arguments = T extends (...args: infer A) => unknown ? A : never; -// Ensures that a type is not undefined -type Defined = T extends undefined ? never : T | Awaited; - -type AtomEffectParam = Arguments>[0]; - -export type UnloadedValue = { - value: undefined; - error: undefined; - retry: undefined; - hasError: undefined; - loading: false; -}; -export type LoadingValue = { - value: undefined; - error: undefined; - retry: undefined; - hasError: undefined; - loading: true; -}; -export type LoadedValue = { - value: Defined; - error: undefined; - retry: () => Promise; // Should refresh to the initial value - hasError: false; - loading: false; -}; -export type FailedValue = { - value: undefined; - error: unknown; - retry: () => Promise; - hasError: true; - loading: false; -}; -export type LoadableValue = UnloadedValue | LoadingValue | LoadedValue | FailedValue; -export type QueriedValue = LoadingValue | LoadedValue | FailedValue; - -const DEF_UNLOADED_VALUE: UnloadedValue = { value: undefined, error: undefined, retry: undefined, hasError: undefined, loading: false }; -const DEF_PENDED_VALUE: LoadingValue = { value: undefined, error: undefined, retry: undefined, hasError: undefined, loading: true }; -function createLoadedValue(value: Defined, retry: () => Promise): LoadedValue { - return { - value, - error: undefined, - retry, - hasError: false, - loading: false - }; -} -function createFailedValue(error: unknown, retry: () => Promise): FailedValue { - return { - value: undefined, - error, - retry, - hasError: true, - loading: false - }; -} - -export function isUnload(loadableValue: LoadableValue): loadableValue is UnloadedValue { - return loadableValue.value === undefined && loadableValue.hasError === undefined && loadableValue.loading === false; -} -export function isPended(loadableValue: LoadableValue): loadableValue is LoadingValue { - return loadableValue.loading === true; -} -export function isFailed(loadableValue: LoadableValue): loadableValue is FailedValue { - return loadableValue.hasError === true; -} -export function isLoaded(loadableValue: LoadableValue): loadableValue is LoadedValue { - return loadableValue.value !== undefined; -} - -interface UnloadedScrollingEnd { - hasMore: undefined | boolean; // Could be set to a boolean if we delete from opposite end while adding new elements - hasError: undefined; - error: undefined; - retry: undefined; - cancel: undefined; - loading: false; -} -interface LoadingScrollingEnd { - hasMore: undefined | boolean; - hasError: undefined; - error: undefined; - retry: (reference: T) => Promise; - cancel: () => void; - loading: true; -} -interface LoadedScrollingEnd { - hasMore: boolean; - hasError: false; - error: undefined; - retry: (reference: T) => Promise; - cancel: () => void; - loading: false; -} -interface FailedScrollingEnd { - hasMore: undefined | boolean; - hasError: true; - error: unknown; - retry: (reference: T) => Promise; - cancel: () => void; - loading: false; -} -export type LoadableScrollingEnd = UnloadedScrollingEnd | LoadingScrollingEnd | LoadedScrollingEnd | FailedScrollingEnd; -const DEF_UNLOADED_SCROLL_END: UnloadedScrollingEnd = { hasMore: undefined, hasError: undefined, error: undefined, retry: undefined, cancel: undefined, loading: false }; -function createLoadingScrollingEnd(retry: (reference: T) => Promise, cancel: () => void): LoadingScrollingEnd { - return { - hasMore: undefined, - hasError: undefined, - error: undefined, - retry, - cancel, - loading: true - }; -} -function createLoadedScrollingEnd(hasMore: boolean, retry: (reference: T) => Promise, cancel: () => void): LoadedScrollingEnd { - return { - hasMore, - hasError: false, - error: undefined, - retry, - cancel, - loading: false - }; -} -function createFailedScrollingEnd(error: unknown, retry: (reference: T) => Promise, cancel: () => void): FailedScrollingEnd { - return { - hasMore: undefined, - hasError: true, - error, - retry, - cancel, - loading: false - }; -} -export function isEndUnload(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is UnloadedScrollingEnd { - return loadableScrollingEnd.hasError === undefined && loadableScrollingEnd.loading === false; -} -export function isEndPended(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadingScrollingEnd { - return loadableScrollingEnd.loading === true; -} -export function isEndFailed(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is FailedScrollingEnd { - return loadableScrollingEnd.hasError === true; -} -export function isEndLoaded(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadedScrollingEnd { - return loadableScrollingEnd.hasError === false; -} - -export type UnloadedValueScrolling = UnloadedValue & { - above: undefined; - below: undefined; -} -export type LoadingValueScrolling = LoadingValue & { - above: undefined; - below: undefined; -} -export type LoadedValueScrolling = LoadedValue & { - above: LoadableScrollingEnd; - below: LoadableScrollingEnd; -} -export type FailedValueScrolling = FailedValue & { - above: undefined; - below: undefined; -} -export type LoadableValueScrolling = UnloadedValueScrolling | LoadingValueScrolling | LoadedValueScrolling | FailedValueScrolling; -const DEF_UNLOADED_SCROLLING_VALUE: UnloadedValueScrolling = { ...DEF_UNLOADED_VALUE, above: undefined, below: undefined }; -const DEF_PENDED_SCROLLING_VALUE: LoadingValueScrolling = { ...DEF_PENDED_VALUE, above: undefined, below: undefined }; -function createLoadedValueScrolling(value: Defined, retry: () => Promise, above: LoadableScrollingEnd, below: LoadableScrollingEnd): LoadedValueScrolling { - return { ...createLoadedValue(value, retry), above, below }; -} -function createFailedValueScrolling(error: unknown, retry: () => Promise): FailedValueScrolling { - return { ...createFailedValue(error, retry), above: undefined, below: undefined } -} +import { createFailedValue, createLoadedValue, DEF_PENDED_VALUE, DEF_UNLOADED_SCROLLING_VALUE, DEF_UNLOADED_VALUE, isFailed, isLoaded, isPended, isUnload, LoadableValue, LoadableValueScrolling } from './loadables'; +import { applyChangedElements, applyIfLoaded, applyListFuncIfLoaded, applyListScrollingFuncIfLoaded, applyNewElements, applyRemovedElements, applyUpdatedElements, guildDataSubscriptionLoadableMultipleEffect, guildDataSubscriptionLoadableSingleEffect, multipleScrollingGuildSubscriptionEffect, useRecoilValueLoadableOrElse } from './atoms-funcs'; export const overlayState = atom({ key: 'overlayState', @@ -207,507 +33,6 @@ export const allGuildsState = atom({ dangerouslyAllowMutability: true }); -// TODO: Consider using 'getCallback' for this and having atom-backed selectors instead of using the atoms directly -// Atoms would have to be set up with destructors that unsubscribe from the guild - -type FetchValueFunc = () => Promise; -function createFetchValueFunc( - atomEffectParam: AtomEffectParam>, - guildId: number, - fetchFunc: (guild: CombinedGuild) => Promise>, -): FetchValueFunc { - const { node, setSelf, getPromise } = atomEffectParam; - const fetchValueFunc = async () => { - const guild = await getPromise(guildState(guildId)); - if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager - - const selfState = await getPromise(node); - if (isPended(selfState)) return; // Don't send another request if we're already loading - - setSelf(DEF_PENDED_VALUE); - try { - const value = await fetchFunc(guild); - setSelf(createLoadedValue(value, fetchValueFunc)); - } catch (e: unknown) { - LOG.error('unable to fetch initial guild metadata', e); - setSelf(createFailedValue(e, fetchValueFunc)); - } - } - return fetchValueFunc; -} - -type FetchValueScrollingFunc = () => Promise; -function createFetchValueScrollingFunc( - atomEffectParam: AtomEffectParam>, - guildId: number, - count: number, - fetchFunc: (guild: CombinedGuild, count: number) => Promise>, - createAboveEndFunc: (result: Defined) => LoadableScrollingEnd, - createBelowEndFunc: (result: Defined) => LoadableScrollingEnd, -): FetchValueScrollingFunc { - const { node, setSelf, getPromise } = atomEffectParam; - const fetchValueReferenceFunc = async () => { - const guild = await getPromise(guildState(guildId)); - if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager - - const selfState = await getPromise(node); - if (!isLoaded(selfState)) return; // Don't send a request if the base LoadableValueScrolling isn't loaded yet - - setSelf(DEF_PENDED_SCROLLING_VALUE); - try { - const result = await fetchFunc(guild, count); - setSelf(createLoadedValueScrolling( - result, - fetchValueReferenceFunc, - createAboveEndFunc(result), - createBelowEndFunc(result) - )); - } catch (e: unknown) { - LOG.error('unable to fetch value scrolling', e); - setSelf(createFailedValueScrolling(e, fetchValueReferenceFunc)) - } - }; - return fetchValueReferenceFunc; -} - -type FetchValueScrollingReferenceFunc = (reference: T) => Promise; -function createFetchValueScrollingReferenceFunc( - atomEffectParam: AtomEffectParam>, - guildId: number, - getFunc: (selfState: LoadedValueScrolling) => LoadableScrollingEnd, - applyEndToSelf: (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => LoadedValueScrolling, - applyResultToSelf: (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => LoadedValueScrolling, - count: number, - fetchReferenceFunc: (guild: CombinedGuild, reference: T, count: number) => Promise -): { - fetchValueReferenceFunc: FetchValueScrollingReferenceFunc, - cancel: () => void -} { - const { node, setSelf, getPromise } = atomEffectParam; - // TODO: Improve cancellation behavior. The way it is now, we have to wait for promises to resolve before we can - // fetch below. On giga-slow internet, this may stink if you fetch messages above, cancel the messages below, and then try to scroll back down to the fetchBottomFunc - // (you'd have to wait for the bottom request to finish before it sends the next fetch) - let canceled = false; - const cancel = () => { canceled = true; }; - const fetchValueReferenceFunc = async (reference: T) => { - const guild = await getPromise(guildState(guildId)); - if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager - - const selfState = await getPromise(node); - if (!isLoaded(selfState)) return; // Don't send a request if the base LoadableValueScrolling isn't loaded yet - - const selfEnd = getFunc(selfState); - if (isEndPended(selfEnd)) return; // Don't send a request if we're already loading - - canceled = false; - setSelf(applyEndToSelf(selfState, createLoadingScrollingEnd(fetchValueReferenceFunc, cancel))); - try { - const result = await fetchReferenceFunc(guild, reference, count); - if (canceled) { - setSelf(applyEndToSelf(selfState, DEF_UNLOADED_SCROLL_END)); - } else { - const hasMore = result.length >= count; - setSelf(applyResultToSelf(selfState, createLoadedScrollingEnd(hasMore, fetchValueReferenceFunc, cancel), result)); - } - } catch (e: unknown) { - if (canceled) { - LOG.error('unable to fetch value based on reference (but we were canceled)', e); - setSelf(applyEndToSelf(selfState, DEF_UNLOADED_SCROLL_END)); - } else { - LOG.error('unable to fetch value based on reference', e); - setSelf(applyEndToSelf(selfState, createFailedScrollingEnd(e, fetchValueReferenceFunc, cancel))); - } - } - }; - return { fetchValueReferenceFunc, cancel }; -} - -// Useful for new-xxx, update-xxx, remove-xxx, conflict-xxx list events -function createEventHandler< - V, // e.g. LoadableValue - ArgsMapResult, // e.g. Member[] - XE extends keyof (Connectable | Conflictable) // e.g. new-members ->( - atomEffectParam: AtomEffectParam, - argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, - applyFunc: (selfState: V, argsResult: ArgsMapResult) => V, -): (Connectable & Conflictable)[XE] { - const { node, setSelf, getPromise } = atomEffectParam; - // 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 - return (async (...args: Arguments<(Connectable & Conflictable)[XE]>) => { - const selfState = await getPromise(node); - const argsResult = argsMap(...args); - setSelf(applyFunc(selfState, argsResult)); - }) as (Connectable & Conflictable)[XE]; -} - -interface SingleEventMappingParams< - T, - V, - UE extends keyof Connectable, - CE extends keyof Conflictable - > { - updatedEvent: { - name: UE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined) => V; - }, - conflictEvent: { - name: CE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined) => V; - } -} - -function listenToSingle< - T, // e.g. GuildMetadata - V, // e.g. LoadableValue - UE extends keyof Connectable, // Update Event - CE extends keyof Conflictable, // Conflict Event ->( - atomEffectParam: AtomEffectParam, - guildId: number, - eventMapping: SingleEventMappingParams, -) { - const { getPromise } = atomEffectParam; - // Listen for updates - let guild: CombinedGuild | null = null; - let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null; - let onConflictFunc: (Connectable & Conflictable)[CE] | null = null; - let closed = false; - (async () => { - guild = await getPromise(guildState(guildId)); - if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager - if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state - - onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc); - onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc); - - guild.on(eventMapping.updatedEvent.name, onUpdateFunc); - guild.on(eventMapping.conflictEvent.name, onConflictFunc); - })(); - const cleanup = () => { - closed = true; - if (guild && onUpdateFunc) guild.off(eventMapping.updatedEvent.name, onUpdateFunc); - if (guild && onConflictFunc) guild.off(eventMapping.conflictEvent.name, onConflictFunc); - } - return cleanup; -} - - -interface MultipleEventMappingParams< - T, - V, - NE extends keyof Connectable, - UE extends keyof Connectable, - RE extends keyof Connectable, - CE extends keyof Conflictable - > { - newEvent: { - name: NE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[NE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined) => V; - }, - updatedEvent: { - name: UE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined) => V; - }, - removedEvent: { - name: RE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[RE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined) => V; - }, - conflictEvent: { - name: CE; - argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Changes; - applyFunc: (selfState: V, argsResult: Changes) => V; - }, -} -function listenToMultiple< - T extends { id: string }, // e.g. Member - V, // e.g. LoadableValue - NE extends keyof Connectable, // New Event - UE extends keyof Connectable, // Update Event - RE extends keyof Connectable, // Remove Event - CE extends keyof Conflictable, // Conflict Event ->( - atomEffectParam: AtomEffectParam, - guildId: number, - eventMapping: MultipleEventMappingParams, -) { - const { getPromise } = atomEffectParam; - // Listen for updates - let guild: CombinedGuild | null = null; - let onNewFunc: (Connectable & Conflictable)[NE] | null = null; - let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null; - let onRemoveFunc: (Connectable & Conflictable)[RE] | null = null; - let onConflictFunc: (Connectable & Conflictable)[CE] | null = null; - let closed = false; - (async () => { - guild = await getPromise(guildState(guildId)); - if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager - if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state - - onNewFunc = createEventHandler(atomEffectParam, eventMapping.newEvent.argsMap, eventMapping.newEvent.applyFunc); - onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc); - onRemoveFunc = createEventHandler(atomEffectParam, eventMapping.removedEvent.argsMap, eventMapping.removedEvent.applyFunc); - onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc); - - guild.on(eventMapping.newEvent.name, onNewFunc); - guild.on(eventMapping.updatedEvent.name, onUpdateFunc); - guild.on(eventMapping.removedEvent.name, onRemoveFunc); - guild.on(eventMapping.conflictEvent.name, onConflictFunc); - })(); - const cleanup = () => { - closed = true; - if (guild && onNewFunc) guild.off(eventMapping.newEvent.name, onNewFunc); - if (guild && onUpdateFunc) guild.off(eventMapping.updatedEvent.name, onUpdateFunc); - if (guild && onRemoveFunc) guild.off(eventMapping.removedEvent.name, onRemoveFunc); - if (guild && onConflictFunc) guild.off(eventMapping.conflictEvent.name, onConflictFunc); - } - return cleanup; -} - - -function guildDataSubscriptionLoadableSingleEffect< - T, // e.g. GuildMetadata - UE extends keyof Connectable, // Update Event - CE extends keyof Conflictable, // Conflict Event - >( - guildId: number, - fetchFunc: (guild: CombinedGuild) => Promise>, - eventMapping: SingleEventMappingParams, UE, CE>, - skipFunc?: () => boolean - ) { - const effect: AtomEffect> = (atomEffectParam: AtomEffectParam>) => { - const { trigger } = atomEffectParam; - if (skipFunc && skipFunc()) return; // Don't run if this atom should be skipped for some reason (e.g. null resourceId) - - const fetchValueFunc = createFetchValueFunc(atomEffectParam, guildId, fetchFunc); - - // Fetch initial value on first get - if (trigger === 'get') { - fetchValueFunc(); - } - - // Listen to changes - const cleanup = listenToSingle(atomEffectParam, guildId, eventMapping); - - return () => { - cleanup(); - } - } - return effect; -} - -function guildDataSubscriptionLoadableMultipleEffect< - T extends { id: string }, - NE extends keyof Connectable, - UE extends keyof Connectable, - RE extends keyof Connectable, - CE extends keyof Conflictable ->( - guildId: number, - fetchFunc: (guild: CombinedGuild) => Promise>, - eventMapping: MultipleEventMappingParams, NE, UE, RE, CE>, -) { - const effect: AtomEffect> = (atomEffectParam) => { - const { trigger } = atomEffectParam; - - const fetchValueFunc = createFetchValueFunc(atomEffectParam, guildId, fetchFunc); - - // Fetch initial value on first get - if (trigger === 'get') { - fetchValueFunc(); - } - - // Listen to changes - const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping); - - return () => { - cleanup(); - } - }; - return effect; -} - -interface ScrollingFetchFuncs { - fetchBottomFunc: (guild: CombinedGuild, count: number) => Promise, - fetchAboveFunc: (guild: CombinedGuild, reference: T, count: number) => Promise, - fetchBelowFunc: (guild: CombinedGuild, reference: T, count: number) => Promise; -} -function multipleScrollingGuildSubscriptionEffect< - T extends { id: string }, - NE extends keyof Connectable, // New Event - UE extends keyof Connectable, // Update Event - RE extends keyof Connectable, // Remove Event - CE extends keyof Conflictable // Conflict Event ->( - guildId: number, - fetchFuncs: ScrollingFetchFuncs, - fetchCount: number, // NOTE: If a fetch returns less than this number of elements, we will no longer try to get more above/below it - maxElements: number, // The maximum number of elements in the scroller. Must be greater than maxFetchElements - sortFunc: (a: T, b: T) => number, - eventMapping: MultipleEventMappingParams, NE, UE, RE, CE> -) { - const effect: AtomEffect> = atomEffectParam => { - const { trigger } = atomEffectParam; - - // Initial fetch (fetches the bottom, "most recent"); - // TODO: Fetch in the middle! (this way we can keep messages in the same place when switching through channels) - const fetchValueBottomFunc = createFetchValueScrollingFunc( - atomEffectParam, - guildId, - fetchCount, - fetchFuncs.fetchBottomFunc, - (result: T[]) => createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), - (_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow) - ); - // Fetch Above a Reference - const { - fetchValueReferenceFunc: fetchValueAboveReferenceFunc, - cancel: cancelAbove - } = createFetchValueScrollingReferenceFunc( - atomEffectParam, - guildId, - (selfState: LoadedValueScrolling) => selfState.above, - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, above: end }), // for "pending, etc" - (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { - let nextValue = result.concat(selfState.value).sort(sortFunc); - let sliced = false; - if (nextValue.length > maxElements) { - nextValue = nextValue.slice(undefined, maxElements); - sliced = true; - } - const loadedValue: LoadedValueScrolling = { - ...selfState, - value: nextValue, - above: end, - below: { - ...selfState.below, - hasMore: sliced ? true : selfState.below.hasMore - } as LoadableScrollingEnd // This is OK since selfState.below is already a LoadableScrollingEnd and we are only modifying hasMore to potentially include a boolean - }; - return loadedValue; - }, - fetchCount, - fetchFuncs.fetchAboveFunc - ) - // Fetch Below a Reference - const { - fetchValueReferenceFunc: fetchValueBelowReferenceFunc, - cancel: cancelBelow - } = createFetchValueScrollingReferenceFunc( - atomEffectParam, - guildId, - (selfState: LoadedValueScrolling) => selfState.below, - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, below: end }), - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd, result: T[]) => { - let nextValue = result.concat(selfState.value).sort(sortFunc); - let sliced = false; - if (nextValue.length > maxElements) { - nextValue = nextValue.slice(nextValue.length - maxElements, undefined); - sliced = true; - } - const loadedValue: LoadedValueScrolling = { - ...selfState, - value: nextValue, - above: { - ...selfState.above, - hasMore: sliced ? true : selfState.above.hasMore - } as LoadableScrollingEnd, - below: end - }; - return loadedValue; - }, - fetchCount, - fetchFuncs.fetchBelowFunc - ) - - // Fetch bottom value on first get - if (trigger === 'get') { - fetchValueBottomFunc(); - } - - // Listen to changes - const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping); - return () => { - cleanup(); - } - }; - return effect; -} - -function applyNewElements(list: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] { - return list.concat(newElements).sort(sortFunc); -} -function applyUpdatedElements(list: T[], updatedElements: T[], sortFunc: (a: T, b: T) => number): T[] { - return list.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); -} -function applyRemovedElements(list: T[], removedElements: T[]): T[] { - const removedIds = new Set(removedElements.map(removedElement => removedElement.id)); - return list.filter(element => !removedIds.has(element.id)); -} - -function applyChangedElements(list: T[], changes: Changes, sortFunc: (a: T, b: T) => number): T[] { - const removedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); - return list - .concat(changes.added) - .map(element => changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? element) - .filter(element => !removedIds.has(element.id)) - .sort(sortFunc); -} - -function applyIfLoaded(selfState: LoadableValue, argsResult: Defined): LoadableValue { - if (!isLoaded(selfState)) return selfState; - return createLoadedValue(argsResult, selfState.retry); -} -function applyListFuncIfLoaded( - applyFunc: (list: T[], argsResult: A, sortFunc: (a: T, b: T) => number) => T[], - sortFunc: (a: T, b: T) => number -): ( - selfState: LoadableValue, - argsResult: A, -) => LoadableValue { - return (selfState: LoadableValue, argsResult: A) => { - if (!isLoaded(selfState)) return selfState; - return createLoadedValue(applyFunc(selfState.value, argsResult, sortFunc), selfState.retry); - }; -} -function applyListScrollingFuncIfLoaded( - applyFunc: (list: T[], argsResult: A, sortFunc: (a: T, b: T) => number) => T[], - sortFunc: (a: T, b: T) => number, - maxElements: number -): ( - selfState: LoadableValueScrolling, - argsResult: A, -) => LoadableValueScrolling { - return (selfState: LoadableValueScrolling, argsResult: A) => { - if (!isLoaded(selfState)) return selfState; - let nextValue = applyFunc(selfState.value, argsResult, sortFunc); - let sliced = false; - if (nextValue.length > maxElements) { - // Slice off the top elements to make space for new elements - // TODO: in guild-subscriptions.ts, I had a way of slicing based on the scroll height. - // This would be very convenient to have. Albeit, new messages *should* be coming to the bottom anyway. - // also, deleted/updated/inserted between messages should (hopefully) not happen very much - nextValue = nextValue.slice(nextValue.length - maxElements, undefined); - sliced = true; - } - const loadedValue: LoadedValueScrolling = { - ...selfState, - value: nextValue, - above: { - ...selfState.above, - hasMore: sliced ? true : selfState.above.hasMore - } as LoadableScrollingEnd, - }; - return loadedValue; - }; -} - // You probably want currGuildMetaState export const guildMetaState = atomFamily, number>({ key: 'guildMetaState', @@ -892,7 +217,7 @@ const guildActiveChannelState = selectorFamily, number>({ dangerouslyAllowMutability: true }); -export const guildChannelMessagesState = atomFamily, { guildId: number, channelId: string }>({ +const guildChannelMessagesState = atomFamily, { guildId: number, channelId: string }>({ key: 'guildChannelMessagesState', default: DEF_UNLOADED_SCROLLING_VALUE, effects_UNSTABLE: ({ guildId, channelId }) => [ @@ -933,7 +258,8 @@ export const guildChannelMessagesState = atomFamily, number>({ @@ -969,7 +295,7 @@ export const guildTokensState = atomFamily, number>({ ] }); -const guildState = selectorFamily({ +export const guildState = selectorFamily({ key: 'guildState', get: (guildId: number) => ({ get }) => { const guildsManager = get(guildsManagerState); @@ -989,6 +315,7 @@ function createCurrentGuildStateGetter(subSelectorFamily: (guildId: number) = return ({ get }: { get: GetRecoilValue }) => { const currGuildId = get(currGuildIdState); if (currGuildId === null) return null; + const value = get(subSelectorFamily(currGuildId)); return value; } @@ -998,6 +325,7 @@ function createCurrentGuildHardValueWithParamStateGetter(subSelectorFamily // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist const currGuildId = get(currGuildIdState); if (currGuildId === null) return unloadedValue; + const value = get(subSelectorFamily(guildIdToParam(currGuildId))); if (value === null) return unloadedValue; return value; @@ -1008,8 +336,20 @@ function createCurrentGuildLoadableStateGetter(subSelectorFamily: (guildId: n // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist const currGuildId = get(currGuildIdState); if (currGuildId === null) return DEF_UNLOADED_VALUE; + const value = get(subSelectorFamily(currGuildId)); - if (value === null) return DEF_UNLOADED_VALUE; + return value; + } +} +function createCurrentGuildActiveChannelLoadableScrollingStateGetter(subSelectorFamily: (param: { guildId: number, channelId: string }) => RecoilState>) { + return ({ get }: { get: GetRecoilValue }) => { + // Use the unloaded value if the current guild / current guild active channel hasn't been selected yet or doesn't exist + const guildId = get(currGuildIdState); + if (guildId === null) return DEF_UNLOADED_SCROLLING_VALUE; + const activeChannelId = get(currGuildActiveChannelIdState); + if (activeChannelId === null) return DEF_UNLOADED_SCROLLING_VALUE; + + const value = get(subSelectorFamily({ guildId, channelId: activeChannelId })); return value; } } @@ -1018,6 +358,7 @@ function createCurrentGuildLoadableWithParamStateGetter(subSelectorFamily: // Use the unloaded value if the current guild hasn't been selected yet or doesn't exist const currGuildId = get(currGuildIdState); if (currGuildId === null) return DEF_UNLOADED_VALUE; + const value = get(subSelectorFamily(guildIdToParam(currGuildId))); if (value === null) return DEF_UNLOADED_VALUE; return value; @@ -1061,30 +402,27 @@ export const currGuildChannelsState = selector>({ get: createCurrentGuildLoadableStateGetter(guildChannelsState), dangerouslyAllowMutability: true }); +export const currGuildActiveChannelIdState = selector({ + key: 'currGuildActiveChannelIdState', + get: createCurrentGuildStateGetter(guildActiveChannelIdState), + dangerouslyAllowMutability: true +}); export const currGuildActiveChannelState = selector>({ key: 'currGuildActiveChannelState', get: createCurrentGuildLoadableStateGetter(guildActiveChannelState), dangerouslyAllowMutability: true }); +export const currGuildActiveChannelMessagesState = selector>({ + key: 'currGuildActiveChannelMessagesState', + get: createCurrentGuildActiveChannelLoadableScrollingStateGetter(guildChannelMessagesState), + dangerouslyAllowMutability: true +}); export const currGuildTokensState = selector>({ key: 'currGuildTokensState', - get: createCurrentGuildLoadableStateGetter(guildTokensState) + get: createCurrentGuildLoadableStateGetter(guildTokensState), + dangerouslyAllowMutability: true }); -// Helper functions for using softImgSrc states -function useLoadedOrElse(loadable: Loadable, ifLoading: T, ifError: T) { - if (loadable.state === 'hasValue') { - return loadable.contents; - } else if (loadable.state === 'loading') { - return ifLoading; - } else { - return ifError; - } -} -function useRecoilValueLoadableOrElse(recoilValue: RecoilValue, ifLoading: T, ifError: T): T { - const loadableValue = useRecoilValueLoadable(recoilValue); - return useLoadedOrElse(loadableValue, ifLoading, ifError); -} export function useRecoilValueSoftImgSrc(recoilValue: RecoilValue): string { return useRecoilValueLoadableOrElse(recoilValue, './img/loading.svg', './img/error.png'); } @@ -1095,7 +433,7 @@ export function initRecoil(guildsManager: GuildsManager) { const setGuilds = useSetRecoilState(allGuildsState); useEffect(() => { setGuildsManager(guildsManager); - }, [guildsManager, setGuildsManager]); + }, [ guildsManager, setGuildsManager ]); useEffect(() => { const updateGuilds = () => { setGuilds(guildsManager.guilds.slice()); } updateGuilds(); @@ -1103,6 +441,6 @@ export function initRecoil(guildsManager: GuildsManager) { return () => { guildsManager.off('update-guilds', updateGuilds); } - }, [guildsManager]); + }, [ guildsManager ]); } diff --git a/src/client/webapp/elements/require/atoms-funcs.ts b/src/client/webapp/elements/require/atoms-funcs.ts new file mode 100644 index 0000000..f47a7db --- /dev/null +++ b/src/client/webapp/elements/require/atoms-funcs.ts @@ -0,0 +1,536 @@ +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 { AtomEffect, Loadable, RecoilValue, useRecoilValueLoadable } from "recoil"; +import CombinedGuild from "../../guild-combined"; +import { Conflictable, Connectable } from "../../guild-types"; +import { createFailedScrollingEnd, createFailedValue, createFailedValueScrolling, createLoadedScrollingEnd, createLoadedValue, createLoadedValueScrolling, createLoadingScrollingEnd, Defined, DEF_PENDED_SCROLLING_VALUE, DEF_PENDED_VALUE, DEF_UNLOADED_SCROLL_END, isEndPended, isLoaded, isPended, LoadableScrollingEnd, LoadableValue, LoadableValueScrolling, LoadedScrollingEnd, LoadedValueScrolling } from "./loadables"; +import { guildState } from './atoms-2'; +import { Changes } from '../../data-types'; + +// General typescript type that infers the arguments of a function +export type Arguments = T extends (...args: infer A) => unknown ? A : never; +export type AtomEffectParam = Arguments>[0]; + +// TODO: Consider using 'getCallback' for this and having atom-backed selectors instead of using the atoms directly +// Atoms would have to be set up with destructors that unsubscribe from the guild + +// "initial" value loaders +export type FetchValueFunc = () => Promise; +export function createFetchValueFunc( + atomEffectParam: AtomEffectParam>, + guildId: number, + fetchFunc: (guild: CombinedGuild) => Promise>, +): FetchValueFunc { + const { node, setSelf, getPromise } = atomEffectParam; + const fetchValueFunc = async () => { + const guild = await getPromise(guildState(guildId)); + if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager + + const selfState = await getPromise(node); + if (isPended(selfState)) return; // Don't send another request if we're already loading + + setSelf(DEF_PENDED_VALUE); + try { + const value = await fetchFunc(guild); + setSelf(createLoadedValue(value, fetchValueFunc)); + } catch (e: unknown) { + LOG.error('unable to fetch initial guild metadata', e); + setSelf(createFailedValue(e, fetchValueFunc)); + } + } + return fetchValueFunc; +} + +export type FetchValueScrollingFunc = () => Promise; +export function createFetchValueScrollingFunc( + atomEffectParam: AtomEffectParam>, + guildId: number, + count: number, + fetchFunc: (guild: CombinedGuild, count: number) => Promise>, + createAboveEndFunc: (result: Defined) => LoadableScrollingEnd, + createBelowEndFunc: (result: Defined) => LoadableScrollingEnd, +): FetchValueScrollingFunc { + const { node, setSelf, getPromise } = atomEffectParam; + const fetchValueReferenceFunc = async () => { + const guild = await getPromise(guildState(guildId)); + if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager + + const selfState = await getPromise(node); + if (isPended(selfState)) return; // Don't send another request if we're already loading + + setSelf(DEF_PENDED_SCROLLING_VALUE); + try { + const result = await fetchFunc(guild, count); + setSelf(createLoadedValueScrolling( + result, + fetchValueReferenceFunc, + createAboveEndFunc(result), + createBelowEndFunc(result) + )); + } catch (e: unknown) { + LOG.error('unable to fetch value scrolling', e); + setSelf(createFailedValueScrolling(e, fetchValueReferenceFunc)) + } + }; + return fetchValueReferenceFunc; +} + +export type FetchValueScrollingReferenceFunc = (reference: T) => Promise; +export function createFetchValueScrollingReferenceFunc( + atomEffectParam: AtomEffectParam>, + guildId: number, + getFunc: (selfState: LoadedValueScrolling) => LoadableScrollingEnd, + applyEndToSelf: (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => LoadedValueScrolling, + applyResultToSelf: (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => LoadedValueScrolling, + count: number, + fetchReferenceFunc: (guild: CombinedGuild, reference: T, count: number) => Promise +): { + fetchValueReferenceFunc: FetchValueScrollingReferenceFunc, + cancel: () => void +} { + const { node, setSelf, getPromise } = atomEffectParam; + // TODO: Improve cancellation behavior. The way it is now, we have to wait for promises to resolve before we can + // fetch below. On giga-slow internet, this may stink if you fetch messages above, cancel the messages below, and then try to scroll back down to the fetchBottomFunc + // (you'd have to wait for the bottom request to finish before it sends the next fetch) + let canceled = false; + const cancel = () => { canceled = true; }; + const fetchValueReferenceFunc = async (reference: T) => { + const guild = await getPromise(guildState(guildId)); + if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager + + const selfState = await getPromise(node); + if (!isLoaded(selfState)) return; // Don't send a request if the base LoadableValueScrolling isn't loaded yet + + const selfEnd = getFunc(selfState); + if (isEndPended(selfEnd)) return; // Don't send a request if we're already loading + + canceled = false; + setSelf(applyEndToSelf(selfState, createLoadingScrollingEnd(fetchValueReferenceFunc, cancel))); + try { + const result = await fetchReferenceFunc(guild, reference, count); + if (canceled) { + setSelf(applyEndToSelf(selfState, DEF_UNLOADED_SCROLL_END)); + } else { + const hasMore = result.length >= count; + setSelf(applyResultToSelf(selfState, createLoadedScrollingEnd(hasMore, fetchValueReferenceFunc, cancel), result)); + } + } catch (e: unknown) { + if (canceled) { + LOG.error('unable to fetch value based on reference (but we were canceled)', e); + setSelf(applyEndToSelf(selfState, DEF_UNLOADED_SCROLL_END)); + } else { + LOG.error('unable to fetch value based on reference', e); + setSelf(applyEndToSelf(selfState, createFailedScrollingEnd(e, fetchValueReferenceFunc, cancel))); + } + } + }; + return { fetchValueReferenceFunc, cancel }; +} + +// Generalized Event Handler +// Useful for new-xxx, update-xxx, remove-xxx, conflict-xxx list events +function createEventHandler< + V, // e.g. LoadableValue + ArgsMapResult, // e.g. Member[] + XE extends keyof (Connectable | Conflictable) // e.g. new-members +>( + atomEffectParam: AtomEffectParam, + argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, + applyFunc: (selfState: V, argsResult: ArgsMapResult) => V, +): (Connectable & Conflictable)[XE] { + const { node, setSelf, getPromise } = atomEffectParam; + // 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 + return (async (...args: Arguments<(Connectable & Conflictable)[XE]>) => { + const selfState = await getPromise(node); + const argsResult = argsMap(...args); + setSelf(applyFunc(selfState, argsResult)); + }) as (Connectable & Conflictable)[XE]; +} + + +// Subscription Effects +export interface SingleEventMappingParams< + T, + V, + UE extends keyof Connectable, + CE extends keyof Conflictable + > { + updatedEvent: { + name: UE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; + applyFunc: (selfState: V, argsResult: Defined) => V; + }, + conflictEvent: { + name: CE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Defined; + applyFunc: (selfState: V, argsResult: Defined) => V; + } +} + +export function listenToSingle< + T, // e.g. GuildMetadata + V, // e.g. LoadableValue + UE extends keyof Connectable, // Update Event + CE extends keyof Conflictable, // Conflict Event +>( + atomEffectParam: AtomEffectParam, + guildId: number, + eventMapping: SingleEventMappingParams, +) { + const { getPromise } = atomEffectParam; + // Listen for updates + let guild: CombinedGuild | null = null; + let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null; + let onConflictFunc: (Connectable & Conflictable)[CE] | null = null; + let closed = false; + (async () => { + guild = await getPromise(guildState(guildId)); + if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager + if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state + + onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc); + onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc); + + guild.on(eventMapping.updatedEvent.name, onUpdateFunc); + guild.on(eventMapping.conflictEvent.name, onConflictFunc); + })(); + const cleanup = () => { + closed = true; + if (guild && onUpdateFunc) guild.off(eventMapping.updatedEvent.name, onUpdateFunc); + if (guild && onConflictFunc) guild.off(eventMapping.conflictEvent.name, onConflictFunc); + } + return cleanup; +} + +export interface MultipleEventMappingParams< + T, + V, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable + > { + newEvent: { + name: NE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[NE]>) => Defined; + applyFunc: (selfState: V, argsResult: Defined) => V; + }, + updatedEvent: { + name: UE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; + applyFunc: (selfState: V, argsResult: Defined) => V; + }, + removedEvent: { + name: RE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[RE]>) => Defined; + applyFunc: (selfState: V, argsResult: Defined) => V; + }, + conflictEvent: { + name: CE; + argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Changes; + applyFunc: (selfState: V, argsResult: Changes) => V; + }, +} +export function listenToMultiple< + T extends { id: string }, // e.g. Member + V, // e.g. LoadableValue + NE extends keyof Connectable, // New Event + UE extends keyof Connectable, // Update Event + RE extends keyof Connectable, // Remove Event + CE extends keyof Conflictable, // Conflict Event +>( + atomEffectParam: AtomEffectParam, + guildId: number, + eventMapping: MultipleEventMappingParams, +) { + const { getPromise } = atomEffectParam; + // Listen for updates + let guild: CombinedGuild | null = null; + let onNewFunc: (Connectable & Conflictable)[NE] | null = null; + let onUpdateFunc: (Connectable & Conflictable)[UE] | null = null; + let onRemoveFunc: (Connectable & Conflictable)[RE] | null = null; + let onConflictFunc: (Connectable & Conflictable)[CE] | null = null; + let closed = false; + (async () => { + guild = await getPromise(guildState(guildId)); + if (guild === null) return; // NOTE: This would only happen if this atom is created before its corresponding guild exists in the guildsManager + if (closed) return; // Make sure not to bind events if this closed while we were fetching the guild state + + onNewFunc = createEventHandler(atomEffectParam, eventMapping.newEvent.argsMap, eventMapping.newEvent.applyFunc); + onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc); + onRemoveFunc = createEventHandler(atomEffectParam, eventMapping.removedEvent.argsMap, eventMapping.removedEvent.applyFunc); + onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc); + + guild.on(eventMapping.newEvent.name, onNewFunc); + guild.on(eventMapping.updatedEvent.name, onUpdateFunc); + guild.on(eventMapping.removedEvent.name, onRemoveFunc); + guild.on(eventMapping.conflictEvent.name, onConflictFunc); + })(); + const cleanup = () => { + closed = true; + if (guild && onNewFunc) guild.off(eventMapping.newEvent.name, onNewFunc); + if (guild && onUpdateFunc) guild.off(eventMapping.updatedEvent.name, onUpdateFunc); + if (guild && onRemoveFunc) guild.off(eventMapping.removedEvent.name, onRemoveFunc); + if (guild && onConflictFunc) guild.off(eventMapping.conflictEvent.name, onConflictFunc); + } + return cleanup; +} + +export function guildDataSubscriptionLoadableSingleEffect< + T, // e.g. GuildMetadata + UE extends keyof Connectable, // Update Event + CE extends keyof Conflictable, // Conflict Event + >( + guildId: number, + fetchFunc: (guild: CombinedGuild) => Promise>, + eventMapping: SingleEventMappingParams, UE, CE>, + skipFunc?: () => boolean + ) { + const effect: AtomEffect> = (atomEffectParam: AtomEffectParam>) => { + const { trigger } = atomEffectParam; + if (skipFunc && skipFunc()) return; // Don't run if this atom should be skipped for some reason (e.g. null resourceId) + + const fetchValueFunc = createFetchValueFunc(atomEffectParam, guildId, fetchFunc); + + // Fetch initial value on first get + if (trigger === 'get') { + fetchValueFunc(); + } + + // Listen to changes + const cleanup = listenToSingle(atomEffectParam, guildId, eventMapping); + + return () => { + cleanup(); + } + } + return effect; +} + +export function guildDataSubscriptionLoadableMultipleEffect< + T extends { id: string }, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable +>( + guildId: number, + fetchFunc: (guild: CombinedGuild) => Promise>, + eventMapping: MultipleEventMappingParams, NE, UE, RE, CE>, +) { + const effect: AtomEffect> = (atomEffectParam) => { + const { trigger } = atomEffectParam; + + const fetchValueFunc = createFetchValueFunc(atomEffectParam, guildId, fetchFunc); + + // Fetch initial value on first get + if (trigger === 'get') { + fetchValueFunc(); + } + + // Listen to changes + const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping); + + return () => { + cleanup(); + } + }; + return effect; +} + +export interface ScrollingFetchFuncs { + fetchBottomFunc: (guild: CombinedGuild, count: number) => Promise, + fetchAboveFunc: (guild: CombinedGuild, reference: T, count: number) => Promise, + fetchBelowFunc: (guild: CombinedGuild, reference: T, count: number) => Promise; +} +export function multipleScrollingGuildSubscriptionEffect< + T extends { id: string }, + NE extends keyof Connectable, // New Event + UE extends keyof Connectable, // Update Event + RE extends keyof Connectable, // Remove Event + CE extends keyof Conflictable // Conflict Event +>( + guildId: number, + fetchFuncs: ScrollingFetchFuncs, + fetchCount: number, // NOTE: If a fetch returns less than this number of elements, we will no longer try to get more above/below it + maxElements: number, // The maximum number of elements in the scroller. Must be greater than maxFetchElements + sortFunc: (a: T, b: T) => number, + eventMapping: MultipleEventMappingParams, NE, UE, RE, CE> +) { + const effect: AtomEffect> = atomEffectParam => { + const { trigger } = atomEffectParam; + + // Initial fetch (fetches the bottom, "most recent"); + // TODO: Fetch in the middle! (this way we can keep messages in the same place when switching through channels) + const fetchValueBottomFunc = createFetchValueScrollingFunc( + atomEffectParam, + guildId, + fetchCount, + fetchFuncs.fetchBottomFunc, + (result: T[]) => createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), + (_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow) + ); + // Fetch Above a Reference + const { + fetchValueReferenceFunc: fetchValueAboveReferenceFunc, + cancel: cancelAbove + } = createFetchValueScrollingReferenceFunc( + atomEffectParam, + guildId, + (selfState: LoadedValueScrolling) => selfState.above, + (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, above: end }), // for "pending, etc" + (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { + let nextValue = result.concat(selfState.value).sort(sortFunc); + let sliced = false; + if (nextValue.length > maxElements) { + nextValue = nextValue.slice(undefined, maxElements); + sliced = true; + } + const loadedValue: LoadedValueScrolling = { + ...selfState, + value: nextValue, + above: end, + below: { + ...selfState.below, + hasMore: sliced ? true : selfState.below.hasMore + } as LoadableScrollingEnd // This is OK since selfState.below is already a LoadableScrollingEnd and we are only modifying hasMore to potentially include a boolean + }; + return loadedValue; + }, + fetchCount, + fetchFuncs.fetchAboveFunc + ) + // Fetch Below a Reference + const { + fetchValueReferenceFunc: fetchValueBelowReferenceFunc, + cancel: cancelBelow + } = createFetchValueScrollingReferenceFunc( + atomEffectParam, + guildId, + (selfState: LoadedValueScrolling) => selfState.below, + (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, below: end }), + (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { + let nextValue = result.concat(selfState.value).sort(sortFunc); + let sliced = false; + if (nextValue.length > maxElements) { + nextValue = nextValue.slice(nextValue.length - maxElements, undefined); + sliced = true; + } + const loadedValue: LoadedValueScrolling = { + ...selfState, + value: nextValue, + above: { + ...selfState.above, + hasMore: sliced ? true : selfState.above.hasMore + } as LoadableScrollingEnd, + below: end + }; + return loadedValue; + }, + fetchCount, + fetchFuncs.fetchBelowFunc + ) + + // Fetch bottom value on first get + if (trigger === 'get') { + LOG.debug('fetching scrolling bottom...'); + fetchValueBottomFunc(); + } + + // Listen to changes + const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping); + return () => { + cleanup(); + } + }; + return effect; +} + +// List management helper functions +export function applyNewElements(list: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] { + return list.concat(newElements).sort(sortFunc); +} +export function applyUpdatedElements(list: T[], updatedElements: T[], sortFunc: (a: T, b: T) => number): T[] { + return list.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); +} +export function applyRemovedElements(list: T[], removedElements: T[]): T[] { + const removedIds = new Set(removedElements.map(removedElement => removedElement.id)); + return list.filter(element => !removedIds.has(element.id)); +} + +export function applyChangedElements(list: T[], changes: Changes, sortFunc: (a: T, b: T) => number): T[] { + const removedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); + return list + .concat(changes.added) + .map(element => changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? element) + .filter(element => !removedIds.has(element.id)) + .sort(sortFunc); +} + +export function applyIfLoaded(selfState: LoadableValue, argsResult: Defined): LoadableValue { + if (!isLoaded(selfState)) return selfState; + return createLoadedValue(argsResult, selfState.retry); +} +export function applyListFuncIfLoaded( + applyFunc: (list: T[], argsResult: A, sortFunc: (a: T, b: T) => number) => T[], + sortFunc: (a: T, b: T) => number +): ( + selfState: LoadableValue, + argsResult: A, +) => LoadableValue { + return (selfState: LoadableValue, argsResult: A) => { + if (!isLoaded(selfState)) return selfState; + return createLoadedValue(applyFunc(selfState.value, argsResult, sortFunc), selfState.retry); + }; +} +export function applyListScrollingFuncIfLoaded( + applyFunc: (list: T[], argsResult: A, sortFunc: (a: T, b: T) => number) => T[], + sortFunc: (a: T, b: T) => number, + maxElements: number +): ( + selfState: LoadableValueScrolling, + argsResult: A, +) => LoadableValueScrolling { + return (selfState: LoadableValueScrolling, argsResult: A) => { + if (!isLoaded(selfState)) return selfState; + let nextValue = applyFunc(selfState.value, argsResult, sortFunc); + let sliced = false; + if (nextValue.length > maxElements) { + // Slice off the top elements to make space for new elements + // TODO: in guild-subscriptions.ts, I had a way of slicing based on the scroll height. + // This would be very convenient to have. Albeit, new messages *should* be coming to the bottom anyway. + // also, deleted/updated/inserted between messages should (hopefully) not happen very much + nextValue = nextValue.slice(nextValue.length - maxElements, undefined); + sliced = true; + } + const loadedValue: LoadedValueScrolling = { + ...selfState, + value: nextValue, + above: { + ...selfState.above, + hasMore: sliced ? true : selfState.above.hasMore + } as LoadableScrollingEnd, + }; + return loadedValue; + }; +} + +// Helper functions for using softImgSrc states +export function useLoadedOrElse(loadable: Loadable, ifLoading: T, ifError: T) { + if (loadable.state === 'hasValue') { + return loadable.contents; + } else if (loadable.state === 'loading') { + return ifLoading; + } else { + return ifError; + } +} +export function useRecoilValueLoadableOrElse(recoilValue: RecoilValue, ifLoading: T, ifError: T): T { + const loadableValue = useRecoilValueLoadable(recoilValue); + return useLoadedOrElse(loadableValue, ifLoading, ifError); +} + diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index 690aa78..249aae4 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -1,6 +1,6 @@ import React from 'react'; -// This file contains JSX elements that can be used in place directly of storing the elements on the disk. +// This file contains JSX elements that can be used in place of storing the elements on the disk. export default class BaseElements { // Scraped directly from discord (#) diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index 92a8187..4a83c32 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -703,7 +703,6 @@ function useMultipleGuildSubscriptionScrolling< } /** - * @deprecated * @param guild The guild to load from * @returns [ * guildMetaResult: The guild's metadata diff --git a/src/client/webapp/elements/require/loadables.ts b/src/client/webapp/elements/require/loadables.ts new file mode 100644 index 0000000..96f11e1 --- /dev/null +++ b/src/client/webapp/elements/require/loadables.ts @@ -0,0 +1,170 @@ +export type Defined = T extends undefined ? never : T | Awaited; + +export type UnloadedValue = { + value: undefined; + error: undefined; + retry: undefined; + hasError: undefined; + loading: false; +}; +export type LoadingValue = { + value: undefined; + error: undefined; + retry: undefined; + hasError: undefined; + loading: true; +}; +export type LoadedValue = { + value: Defined; + error: undefined; + retry: () => Promise; // Should refresh to the initial value + hasError: false; + loading: false; +}; +export type FailedValue = { + value: undefined; + error: unknown; + retry: () => Promise; + hasError: true; + loading: false; +}; +export type LoadableValue = UnloadedValue | LoadingValue | LoadedValue | FailedValue; +export type QueriedValue = LoadingValue | LoadedValue | FailedValue; + +export const DEF_UNLOADED_VALUE: UnloadedValue = { value: undefined, error: undefined, retry: undefined, hasError: undefined, loading: false }; +export const DEF_PENDED_VALUE: LoadingValue = { value: undefined, error: undefined, retry: undefined, hasError: undefined, loading: true }; +export function createLoadedValue(value: Defined, retry: () => Promise): LoadedValue { + return { + value, + error: undefined, + retry, + hasError: false, + loading: false + }; +} +export function createFailedValue(error: unknown, retry: () => Promise): FailedValue { + return { + value: undefined, + error, + retry, + hasError: true, + loading: false + }; +} + +export function isUnload(loadableValue: LoadableValue): loadableValue is UnloadedValue { + return loadableValue.value === undefined && loadableValue.hasError === undefined && loadableValue.loading === false; +} +export function isPended(loadableValue: LoadableValue): loadableValue is LoadingValue { + return loadableValue.loading === true; +} +export function isFailed(loadableValue: LoadableValue): loadableValue is FailedValue { + return loadableValue.hasError === true; +} +export function isLoaded(loadableValue: LoadableValue): loadableValue is LoadedValue { + return loadableValue.value !== undefined; +} + +export interface UnloadedScrollingEnd { + hasMore: undefined | boolean; // Could be set to a boolean if we delete from opposite end while adding new elements + hasError: undefined; + error: undefined; + retry: undefined; + cancel: undefined; + loading: false; +} +export interface LoadingScrollingEnd { + hasMore: undefined | boolean; + hasError: undefined; + error: undefined; + retry: (reference: T) => Promise; + cancel: () => void; + loading: true; +} +export interface LoadedScrollingEnd { + hasMore: boolean; + hasError: false; + error: undefined; + retry: (reference: T) => Promise; + cancel: () => void; + loading: false; +} +export interface FailedScrollingEnd { + hasMore: undefined | boolean; + hasError: true; + error: unknown; + retry: (reference: T) => Promise; + cancel: () => void; + loading: false; +} +export type LoadableScrollingEnd = UnloadedScrollingEnd | LoadingScrollingEnd | LoadedScrollingEnd | FailedScrollingEnd; +export const DEF_UNLOADED_SCROLL_END: UnloadedScrollingEnd = { hasMore: undefined, hasError: undefined, error: undefined, retry: undefined, cancel: undefined, loading: false }; +export function createLoadingScrollingEnd(retry: (reference: T) => Promise, cancel: () => void): LoadingScrollingEnd { + return { + hasMore: undefined, + hasError: undefined, + error: undefined, + retry, + cancel, + loading: true + }; +} +export function createLoadedScrollingEnd(hasMore: boolean, retry: (reference: T) => Promise, cancel: () => void): LoadedScrollingEnd { + return { + hasMore, + hasError: false, + error: undefined, + retry, + cancel, + loading: false + }; +} +export function createFailedScrollingEnd(error: unknown, retry: (reference: T) => Promise, cancel: () => void): FailedScrollingEnd { + return { + hasMore: undefined, + hasError: true, + error, + retry, + cancel, + loading: false + }; +} +export function isEndUnload(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is UnloadedScrollingEnd { + return loadableScrollingEnd.hasError === undefined && loadableScrollingEnd.loading === false; +} +export function isEndPended(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadingScrollingEnd { + return loadableScrollingEnd.loading === true; +} +export function isEndFailed(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is FailedScrollingEnd { + return loadableScrollingEnd.hasError === true; +} +export function isEndLoaded(loadableScrollingEnd: LoadableScrollingEnd): loadableScrollingEnd is LoadedScrollingEnd { + return loadableScrollingEnd.hasError === false; +} + +export type UnloadedValueScrolling = UnloadedValue & { + above: undefined; + below: undefined; +} +export type LoadingValueScrolling = LoadingValue & { + above: undefined; + below: undefined; +} +export type LoadedValueScrolling = LoadedValue & { + above: LoadableScrollingEnd; + below: LoadableScrollingEnd; +} +export type FailedValueScrolling = FailedValue & { + above: undefined; + below: undefined; +} +export type LoadableValueScrolling = UnloadedValueScrolling | LoadingValueScrolling | LoadedValueScrolling | FailedValueScrolling; +export const DEF_UNLOADED_SCROLLING_VALUE: UnloadedValueScrolling = { ...DEF_UNLOADED_VALUE, above: undefined, below: undefined }; +export const DEF_PENDED_SCROLLING_VALUE: LoadingValueScrolling = { ...DEF_PENDED_VALUE, above: undefined, below: undefined }; +export function createLoadedValueScrolling(value: Defined, retry: () => Promise, above: LoadableScrollingEnd, below: LoadableScrollingEnd): LoadedValueScrolling { + return { ...createLoadedValue(value, retry), above, below }; +} +export function createFailedValueScrolling(error: unknown, retry: () => Promise): FailedValueScrolling { + return { ...createFailedValue(error, retry), above: undefined, below: undefined } +} + diff --git a/src/client/webapp/elements/sections/connection-info.tsx b/src/client/webapp/elements/sections/connection-info.tsx index 601ebf0..33a8d59 100644 --- a/src/client/webapp/elements/sections/connection-info.tsx +++ b/src/client/webapp/elements/sections/connection-info.tsx @@ -4,7 +4,8 @@ import MemberElement, { DummyMember } from '../lists/components/member-element'; import ConnectionInfoContextMenu from '../contexts/context-menu-connection-info'; import { useContextMenu } from '../require/react-helper'; import { useRecoilValue } from 'recoil'; -import { currGuildSelfMemberState, currGuildState, isFailed, isLoaded, isPended, isUnload } from '../require/atoms-2'; +import { currGuildSelfMemberState, currGuildState } from '../require/atoms-2'; +import { isFailed, isLoaded, isPended, isUnload } from '../require/loadables'; const ConnectionInfo: FC = () => { const rootRef = useRef(null); diff --git a/src/client/webapp/elements/sections/guild-title.tsx b/src/client/webapp/elements/sections/guild-title.tsx index 44d6c17..f47832f 100644 --- a/src/client/webapp/elements/sections/guild-title.tsx +++ b/src/client/webapp/elements/sections/guild-title.tsx @@ -1,7 +1,8 @@ import React, { FC, useMemo, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import GuildTitleContextMenu from '../contexts/context-menu-guild-title'; -import { currGuildMetaState, currGuildSelfMemberState, isLoaded } from '../require/atoms-2'; +import { currGuildMetaState, currGuildSelfMemberState } from '../require/atoms-2'; +import { isLoaded } from '../require/loadables'; import { useContextMenu } from '../require/react-helper'; const GuildTitle: FC = () => { diff --git a/src/client/webapp/elements/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index 0121d3a..6b2fc02 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -13,7 +13,8 @@ import ConnectionInfo from './connection-info'; import GuildTitle from './guild-title'; import SendMessage from './send-message'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { currGuildActiveChannelState, currGuildChannelsState, currGuildSelfMemberState, currGuildState, guildActiveChannelIdState, isLoaded, isUnload } from '../require/atoms-2'; +import { currGuildActiveChannelState, currGuildChannelsState, currGuildSelfMemberState, currGuildState, guildActiveChannelIdState } from '../require/atoms-2'; +import { isLoaded, isUnload } from '../require/loadables'; const GuildElement: FC = () => { // TODO: Handle fetch errors by allowing for retry @@ -26,6 +27,14 @@ const GuildElement: FC = () => { const activeChannel = useRecoilValue(currGuildActiveChannelState); const setActiveChannelId = useSetRecoilState(guildActiveChannelIdState(guild?.id ?? -1)); + // useEffect(() => { + // LOG.debug('guild changed', { guildId: guild?.id ?? '' }); + // }, [ guild ]); + // useEffect(() => { + // LOG.debug('active channel changed', { activeChannel }); + // }, [ activeChannel ]) + + const [ fetchMessagesRetryCallable, setFetchMessagesRetryCallable ] = useState<(() => Promise) | null>(null); // If the active channel isn't set yet, set it to the first of the channels