From df832f7294849803eeaa30adafd5194a220d3f86 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sun, 6 Feb 2022 15:23:02 -0600 Subject: [PATCH] working state - channel messages being prepared --- src/client/webapp/elements/require/atoms-2.ts | 529 ++++++++++++++---- 1 file changed, 413 insertions(+), 116 deletions(-) diff --git a/src/client/webapp/elements/require/atoms-2.ts b/src/client/webapp/elements/require/atoms-2.ts index 78d2881..4d93781 100644 --- a/src/client/webapp/elements/require/atoms-2.ts +++ b/src/client/webapp/elements/require/atoms-2.ts @@ -5,11 +5,12 @@ 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 { Changes, Channel, GuildMetadata, Member, Resource, ShouldNeverHappenError, Token } from "../../data-types"; +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; @@ -22,20 +23,20 @@ export type UnloadedValue = { value: undefined; error: undefined; retry: undefined; - hasError: false; + hasError: undefined; loading: false; }; export type LoadingValue = { value: undefined; error: undefined; retry: undefined; - hasError: false; + hasError: undefined; loading: true; }; export type LoadedValue = { value: Defined; error: undefined; - retry: () => Promise; + retry: () => Promise; // Should refresh to the initial value hasError: false; loading: false; }; @@ -49,8 +50,8 @@ export type FailedValue = { 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: false, loading: false }; -const DEF_PENDED_VALUE: LoadingValue = { value: undefined, error: undefined, retry: undefined, hasError: false, loading: true }; +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, @@ -71,7 +72,7 @@ function createFailedValue(error: unknown, retry: () => Promise): FailedVa } export function isUnload(loadableValue: LoadableValue): loadableValue is UnloadedValue { - return loadableValue.value === undefined && loadableValue.hasError === false && loadableValue.loading === false; + return loadableValue.value === undefined && loadableValue.hasError === undefined && loadableValue.loading === false; } export function isPended(loadableValue: LoadableValue): loadableValue is LoadingValue { return loadableValue.loading === true; @@ -83,6 +84,109 @@ export function isLoaded(loadableValue: LoadableValue): loadableValue is L 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 } +} + export const overlayState = atom({ key: 'overlayState', default: null @@ -132,17 +236,101 @@ function createFetchValueFunc( 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[] - Addins, // e.g. FetchValueFunc to be called later to re-load members XE extends keyof (Connectable | Conflictable) // e.g. new-members >( atomEffectParam: AtomEffectParam, argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, - applyFunc: (selfState: V, argsResult: ArgsMapResult, addins: Addins) => V, - addins: Addins, + 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 @@ -150,26 +338,25 @@ function createEventHandler< return (async (...args: Arguments<(Connectable & Conflictable)[XE]>) => { const selfState = await getPromise(node); const argsResult = argsMap(...args); - setSelf(applyFunc(selfState, argsResult, addins)); + setSelf(applyFunc(selfState, argsResult)); }) as (Connectable & Conflictable)[XE]; } interface SingleEventMappingParams< T, V, - Addins, UE extends keyof Connectable, CE extends keyof Conflictable -> { + > { updatedEvent: { name: UE; argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Defined) => V; }, conflictEvent: { name: CE; argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Defined) => V; } } @@ -181,8 +368,7 @@ function listenToSingle< >( atomEffectParam: AtomEffectParam, guildId: number, - eventMapping: SingleEventMappingParams, - fetchValueFunc: FetchValueFunc, + eventMapping: SingleEventMappingParams, ) { const { getPromise } = atomEffectParam; // Listen for updates @@ -195,8 +381,8 @@ function listenToSingle< 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, fetchValueFunc); - onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc, fetchValueFunc); + 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); @@ -213,31 +399,30 @@ function listenToSingle< interface MultipleEventMappingParams< T, V, - Addins, 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, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Defined) => V; }, updatedEvent: { name: UE; argsMap: (...args: Arguments<(Connectable & Conflictable)[UE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Defined) => V; }, removedEvent: { name: RE; argsMap: (...args: Arguments<(Connectable & Conflictable)[RE]>) => Defined; - applyFunc: (selfState: V, argsResult: Defined, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Defined) => V; }, conflictEvent: { name: CE; argsMap: (...args: Arguments<(Connectable & Conflictable)[CE]>) => Changes; - applyFunc: (selfState: V, argsResult: Changes, addins: Addins) => V; + applyFunc: (selfState: V, argsResult: Changes) => V; }, } function listenToMultiple< @@ -246,12 +431,11 @@ function listenToMultiple< NE extends keyof Connectable, // New Event UE extends keyof Connectable, // Update Event RE extends keyof Connectable, // Remove Event - CE extends keyof Conflictable // Conflict Event + CE extends keyof Conflictable, // Conflict Event >( atomEffectParam: AtomEffectParam, guildId: number, - eventMapping: MultipleEventMappingParams, - fetchValueFunc: FetchValueFunc, + eventMapping: MultipleEventMappingParams, ) { const { getPromise } = atomEffectParam; // Listen for updates @@ -266,10 +450,10 @@ function listenToMultiple< 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, fetchValueFunc); - onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc, fetchValueFunc); - onRemoveFunc = createEventHandler(atomEffectParam, eventMapping.removedEvent.argsMap, eventMapping.removedEvent.applyFunc, fetchValueFunc); - onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc, fetchValueFunc); + 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); @@ -291,12 +475,12 @@ 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, FetchValueFunc, UE, CE>, - skipFunc?: () => boolean -) { + >( + 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) @@ -309,7 +493,7 @@ function guildDataSubscriptionLoadableSingleEffect< } // Listen to changes - const cleanup = listenToSingle(atomEffectParam, guildId, eventMapping, fetchValueFunc); + const cleanup = listenToSingle(atomEffectParam, guildId, eventMapping); return () => { cleanup(); @@ -327,7 +511,7 @@ function guildDataSubscriptionLoadableMultipleEffect< >( guildId: number, fetchFunc: (guild: CombinedGuild) => Promise>, - eventMapping: MultipleEventMappingParams, FetchValueFunc, NE, UE, RE, CE>, + eventMapping: MultipleEventMappingParams, NE, UE, RE, CE>, ) { const effect: AtomEffect> = (atomEffectParam) => { const { trigger } = atomEffectParam; @@ -340,7 +524,7 @@ function guildDataSubscriptionLoadableMultipleEffect< } // Listen to changes - const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping, fetchValueFunc); + const cleanup = listenToMultiple(atomEffectParam, guildId, eventMapping); return () => { cleanup(); @@ -349,38 +533,112 @@ function guildDataSubscriptionLoadableMultipleEffect< return effect; } -// TODO: ScrollableLoadableValue -// interface ScrollingList { -// list: T[]; -// hasMoreAbove: boolean; -// hasMoreBelow: boolean; -// } -// 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, -// fetchBottomFunc: (guild: CombinedGuild, count: number) => Promise, -// fetchAboveFunc: (guild: CombinedGuild, reference: T, count: number) => Promise, -// fetchBelowFunc: (guild: CombinedGuild, reference: T, count: number) => Promise, -// 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 -// ) { -// const effect: AtomEffect>> = ({ node, trigger, setSelf, getPromise }) => { -// const fetchValueFunc = createFetchValueFunc(getPromise, guildId, node, setSelf, async (guild: CombinedGuild) => { -// const list = await fetchBottomFunc(guild, fetchCount); -// return { list, hasMoreBelow: false, hasMoreAbove: list.length <= fetchCount }; -// }); -// -// // TODO: listen for changes -// }; -// 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); @@ -402,22 +660,51 @@ function applyChangedElements(list: T[], changes: Chan .sort(sortFunc); } -function applyIfLoaded(selfState: LoadableValue, argsResult: Defined, fetchValueFunc: FetchValueFunc): LoadableValue { +function applyIfLoaded(selfState: LoadableValue, argsResult: Defined): LoadableValue { if (!isLoaded(selfState)) return selfState; - return createLoadedValue(argsResult, fetchValueFunc); + 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, - fetchValueFunc: FetchValueFunc -) => LoadableValue { - return (selfState: LoadableValue, argsResult: A, fetchValueFunc: FetchValueFunc) => { +) => LoadableValue { + return (selfState: LoadableValue, argsResult: A) => { if (!isLoaded(selfState)) return selfState; - return createLoadedValue(applyFunc(selfState.value, argsResult, sortFunc), fetchValueFunc); + 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; }; } @@ -485,7 +772,7 @@ export const guildResourceSoftImgSrcState = selectorFamily, number>({ dangerouslyAllowMutability: true }); -// export const guildChannelMessagesState = atomFamily>, { guildId: number, channelId: string }>({ -// key: 'guildChannelMessagesState', -// default: DEF_UNLOADED_VALUE, -// effects_UNSTABLE: ({ guildId, channelId }) => [ -// multipleScrollingGuildSubscriptionEffect( -// guildId, -// async (guild: CombinedGuild, count: number) => await guild.fetchMessagesRecent(channelId, count), -// async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesBefore(channelId, reference._order, count), -// async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesAfter(channelId, reference._order, count), -// Globals.MESSAGES_PER_REQUEST, -// Globals.MAX_CURRENT_MESSAGES, -// Message.sortOrder, -// { -// newEventName: 'new-messages', -// newEventArgsMap: (newMessages: Message[]) => newMessages.filter(message => message.channel.id === channelId), -// newEventCondition: (messages: Message[]) => messages.length > 0, -// updatedEventName: 'update-messages', -// updatedEventArgsMap: (updatedMessages: Message[]) => updatedMessages.filter(message => message.channel.id === channelId), -// updatedEventCondition: (messages: Message[]) => messages.length > 0, -// removedEventName: 'remove-messages', -// removedEventArgsMap: (removedMessages: Message[]) => removedMessages.filter(message => message.channel.id === channelId), -// removedEventCondition: (messages: Message[]) => messages.length > 0, -// conflictEventName: 'conflict-messages', -// conflictEventArgsMap: (_query: PartialMessageListQuery, _changesType: AutoVerifierChangesType, changes: Changes) => ({ -// added: changes.added.filter(message => message.channel.id === channelId), -// updated: changes.updated.filter(change => change.newDataPoint.channel.id === channelId), -// deleted: changes.deleted.filter(message => message.channel.id === channelId), -// }), -// conflictEventCondition: (changes: Changes) => changes.added.length + changes.updated.length + changes.deleted.length > 0, -// } -// ) -// ] -// }); +export const guildChannelMessagesState = atomFamily, { guildId: number, channelId: string }>({ + key: 'guildChannelMessagesState', + default: DEF_UNLOADED_SCROLLING_VALUE, + effects_UNSTABLE: ({ guildId, channelId }) => [ + multipleScrollingGuildSubscriptionEffect( + guildId, + { + fetchBottomFunc: async (guild: CombinedGuild, count: number) => await guild.fetchMessagesRecent(channelId, count), + fetchAboveFunc: async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesBefore(channelId, reference._order, count), + fetchBelowFunc: async (guild: CombinedGuild, reference: Message, count: number) => await guild.fetchMessagesAfter(channelId, reference._order, count), + }, + Globals.MESSAGES_PER_REQUEST, + Globals.MAX_CURRENT_MESSAGES, + Message.sortOrder, + { + newEvent: { + name: 'new-messages', + argsMap: (newMessages: Message[]) => newMessages.filter(message => message.channel.id === channelId), + applyFunc: applyListScrollingFuncIfLoaded(applyNewElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES) + }, + updatedEvent: { + name: 'update-messages', + argsMap: (updatedMessages: Message[]) => updatedMessages.filter(message => message.channel.id === channelId), + applyFunc: applyListScrollingFuncIfLoaded(applyUpdatedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES) + }, + removedEvent: { + name: 'remove-messages', + argsMap: (removedMessages: Message[]) => removedMessages.filter(message => message.channel.id === channelId), + applyFunc: applyListScrollingFuncIfLoaded(applyRemovedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES) + }, + conflictEvent: { + name: 'conflict-messages', + argsMap: (_query, _changeType, changes: Changes) => ({ + added: changes.added.filter(message => message.channel.id === channelId), + updated: changes.updated.filter(change => change.newDataPoint.channel.id === channelId), + deleted: changes.deleted.filter(message => message.channel.id === channelId), + }), + applyFunc: applyListScrollingFuncIfLoaded(applyChangedElements, Message.sortOrder, Globals.MAX_CURRENT_MESSAGES) + }, + } + ) + ] +}); export const guildTokensState = atomFamily, number>({ key: 'guildTokensState', @@ -798,7 +1095,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(); @@ -806,6 +1103,6 @@ export function initRecoil(guildsManager: GuildsManager) { return () => { guildsManager.off('update-guilds', updateGuilds); } - }, [ guildsManager ]); + }, [guildsManager]); }