cordis/archive/guild-subscriptions.ts

940 lines
38 KiB
TypeScript
Raw Normal View History

2021-12-13 04:01:30 +00:00
import * as electronRemote from '@electron/remote';
const electronConsole = electronRemote.getGlobal('console') as Console;
2022-07-05 23:46:05 +00:00
import Logger from '../../../../logger';
2021-12-13 04:01:30 +00:00
const LOG = Logger.create(__filename, electronConsole);
2021-12-24 23:37:27 +00:00
import { Changes, GuildMetadata, Member, Message, Resource } from "../../data-types";
2021-12-13 04:01:30 +00:00
import CombinedGuild from "../../guild-combined";
2022-01-23 02:19:15 +00:00
import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
2021-12-13 04:01:30 +00:00
import { AutoVerifierChangesType } from "../../auto-verifier";
import { Conflictable, Connectable } from "../../guild-types";
2021-12-24 23:37:27 +00:00
import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args';
import { Token, Channel } from '../../data-types';
import { useIsMountedRef, useOneTimeAsyncAction } from './react-helper';
2021-12-24 23:37:27 +00:00
import Globals from '../../globals';
2021-12-25 18:00:20 +00:00
import ElementsUtil from './elements-util';
2021-12-13 04:01:30 +00:00
// Abuse closures to get state in case it changed after an await call
// TODO: This function may be useless...
function getStateAfterAwait<T>(setState: Dispatch<SetStateAction<T>>): T {
let x: unknown;
setState(state => {
x = state;
return state;
});
return x as T;
}
// Parameters used by base guildSubscription to fetch the initial value
2021-12-13 04:01:30 +00:00
interface EffectParams<T> {
guild: CombinedGuild;
// TODO: I changed this from value: T | null to just value: T. I think
// this file (and potentially some others) has a bunch of spots that use .value?.xxx
// where it doesn't need to. Maybe there is an ESLint thing for this?
onFetch: (value: T, valueGuild: CombinedGuild) => void;
2021-12-13 04:01:30 +00:00
onFetchError: (e: unknown) => void;
bindEventsFunc: () => void;
unbindEventsFunc: () => void;
2021-12-13 04:01:30 +00:00
}
// General typescript type that infers the arguments of a function
2021-12-13 04:01:30 +00:00
type Arguments<T> = T extends (...args: infer A) => unknown ? A : never;
// The result of a general subscription. Includes the value and associated guild
2022-02-07 01:24:25 +00:00
interface SubscriptionResult<T> {
value: T;
guild: CombinedGuild;
}
// The result of a scrolling subscription. Includes the state of each end of the list, the list, and the associated guild
2022-02-07 01:24:25 +00:00
interface ScrollingSubscriptionResult<T> {
value: {
ends: { hasMoreAbove: boolean, hasMoreBelow: boolean };
elements: T[];
},
guild: CombinedGuild;
}
// Ensures that a nullable subscriptionResult has a value and has been fetched
2022-02-07 01:24:25 +00:00
function isNonNullAndHasValue<T>(subscriptionResult: SubscriptionResult<T | null> | null): subscriptionResult is SubscriptionResult<T> {
return !!(subscriptionResult !== null && subscriptionResult.value !== null);
}
// Maps a "single" data subscription's events (like guild-metadata)
2021-12-13 05:25:23 +00:00
interface SingleEventMappingParams<T, UE extends keyof Connectable, CE extends keyof Conflictable> {
updatedEventName: UE;
updatedEventArgsMap: (...args: Arguments<Connectable[UE]>) => T;
2021-12-13 04:01:30 +00:00
conflictEventName: CE;
2021-12-13 05:25:23 +00:00
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => T;
}
// Maps a "multiple" data subscription's events (like channels)
// This data subscription can be incrementally updated
2021-12-13 05:25:23 +00:00
interface MultipleEventMappingParams<
T,
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable
> {
newEventName: NE;
newEventArgsMap: (...args: Arguments<Connectable[NE]>) => T[]; // list of new elements
updatedEventName: UE;
updatedEventArgsMap: (...args: Arguments<Connectable[UE]>) => T[]; // list of updated elements
removedEventName: RE;
removedEventArgsMap: (...args: Arguments<Connectable[RE]>) => T[]; // list of removed elements
conflictEventName: CE;
conflictEventArgsMap: (...args: Arguments<Conflictable[CE]>) => Changes<T>;
2021-12-24 23:37:27 +00:00
sortFunc: (a: T, b: T) => number; // Friendly reminder that v8 uses timsort so this is O(n) for pre-sorted stuff
}
2021-12-13 04:01:30 +00:00
/**
* Core function to subscribe to a general fetchable guild function
* @param subscriptionParams Event callback functions
* @param fetchFunc Function that can be called to fetch the data for the subscription. This function will be called automatically if it is changed.
* Typically, this function will be set up in a useCallback with a dependency on at least the guild.
* If the fetch function returns null, this will not call "onFetch". This allows results to stay until the guilds are updated.
* @returns [
* fetchRetryCallable Can be called to re-fetch the data
* ]
*/
function useGuildSubscriptionEffect<T>(
subscriptionParams: EffectParams<T>,
fetchFunc: () => Promise<T | null>
): [ fetchRetryCallable: () => Promise<void> ] {
const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams;
2021-12-24 23:37:27 +00:00
const isMounted = useIsMountedRef();
const guildRef = useRef<CombinedGuild>(guild);
guildRef.current = guild;
2021-12-13 04:01:30 +00:00
const fetchManagerFunc = useCallback(async () => {
if (!isMounted.current) return;
if (guildRef.current !== guild) return;
try {
const value = await fetchFunc();
2021-12-24 23:37:27 +00:00
if (!isMounted.current) return;
if (guildRef.current !== guild) return; // Don't even call onFetch if we changed guilds. TODO: Test this
if (!value) return; // we decided not to fetch, typically since there are conflicting guilds
onFetch(value, guild);
} catch (e: unknown) {
LOG.error('error fetching for subscription', e);
2021-12-24 23:37:27 +00:00
if (!isMounted.current) return;
if (guildRef.current !== guild) return;
onFetchError(e);
}
}, [ fetchFunc ]);
2021-12-13 05:25:23 +00:00
useEffect(() => {
// Bind guild events to make sure we have the most up to date information
guild.on('connect', fetchManagerFunc);
guild.on('disconnect', fetchManagerFunc);
bindEventsFunc();
2021-12-24 23:37:27 +00:00
// Fetch the data once
fetchManagerFunc();
return () => {
// Unbind the events so that we don't have any memory leaks
guild.off('connect', fetchManagerFunc);
guild.off('disconnect', fetchManagerFunc);
unbindEventsFunc();
}
}, [ fetchManagerFunc ]);
2021-12-24 23:37:27 +00:00
return [ fetchManagerFunc ];
}
/**
* Subscribe to a fetchable guild function that returns a single element (i.e. GuildMetadata)
* @param guild The guild to listen for changes on
* @param eventMappingParams The events to use to listen for changes (such as updates and conflicts)
* @param fetchFunc The function to call to fetch initial data
* @returns [
* lastResult: The last result of the fetch (null = not fetched yet)
* fetchError: The error from the fetch
* ]
*/
function useSingleGuildSubscription<T, UE extends keyof Connectable, CE extends keyof Conflictable>(
guild: CombinedGuild,
eventMappingParams: SingleEventMappingParams<T, UE, CE>,
fetchFunc: () => Promise<T | null>
): [lastResult: SubscriptionResult<T> | null, fetchError: unknown | null] {
const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams;
const isMounted = useIsMountedRef();
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ lastResult, setLastResult ] = useState<{ value: T, guild: CombinedGuild } | null>(null);
const onFetch = useCallback((fetchValue: T | null, fetchValueGuild: CombinedGuild) => {
setLastResult(fetchValue ? { value: fetchValue, guild: fetchValueGuild } : null);
setFetchError(null);
}, []);
const onFetchError = useCallback((e: unknown) => {
setFetchError(e);
setLastResult(null)
}, []);
// I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMounted.current) return;
const value = updatedEventArgsMap(...args);
setLastResult(lastResult => {
if (guild !== lastResult?.guild) return lastResult;
return { value: value, guild: guild };
});
}, [ guild ]) as (Connectable & Conflictable)[UE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMounted.current) return;
const value = conflictEventArgsMap(...args);
setLastResult(lastResult => {
if (guild !== lastResult?.guild) return lastResult;
return { value: value, guild: guild };
});
}, [ guild ]) as (Connectable & Conflictable)[CE];
const bindEventsFunc = useCallback(() => {
guild.on(updatedEventName, boundUpdateFunc);
guild.on(conflictEventName, boundConflictFunc);
}, [ boundUpdateFunc, boundConflictFunc ]);
const unbindEventsFunc = useCallback(() => {
guild.off(updatedEventName, boundUpdateFunc);
guild.off(conflictEventName, boundConflictFunc);
}, [ boundUpdateFunc, boundConflictFunc ]);
useGuildSubscriptionEffect({
guild,
onFetch,
onFetchError,
bindEventsFunc,
unbindEventsFunc
}, fetchFunc);
return [ lastResult, fetchError ];
}
/**
* @param guild The current guild
* @param eventMappingParams The mappings to bind to guild events for updates, conflicts, etc
* @param fetchFunc The function to load the initial data
* @returns [
* fetchRetryCallable: Can be called to re-fetch
* lastResult: The last result of the fetch (null = not fetched yet)
* fetchError: The error from the fetch
* ]
*/
function useMultipleGuildSubscription<
T extends { id: string },
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable
>(
guild: CombinedGuild,
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
fetchFunc: () => Promise<T[] | null>
): [
fetchRetryCallable: () => Promise<void>,
lastResult: SubscriptionResult<T[]> | null,
fetchError: unknown | null,
] {
const {
newEventName, newEventArgsMap,
updatedEventName, updatedEventArgsMap,
removedEventName, removedEventArgsMap,
conflictEventName, conflictEventArgsMap,
sortFunc
} = eventMappingParams;
const isMounted = useIsMountedRef();
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ lastResult, setLastResult ] = useState<{ value: T[], guild: CombinedGuild } | null>(null);
const onFetch = useCallback((fetchValue: T[], fetchValueGuild: CombinedGuild) => {
if (fetchValue) fetchValue.sort(sortFunc);
/* LOG.debug('onFetch', { valueType: (fetchValue?.length && (fetchValue[0] as T).constructor.name) ?? 'null', guild: fetchValueGuild.id }); */
setLastResult({ value: fetchValue, guild: fetchValueGuild });
setFetchError(null);
}, [ sortFunc ]);
const onFetchError = useCallback((e: unknown) => {
setFetchError(e);
setLastResult(null)
}, []);
// I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
if (!isMounted.current) return;
const newElements = newEventArgsMap(...args);
setLastResult((lastResult) => {
// TODO: There's a bug in this and other functions like it in the file where if you switch
// from one guild and then back again, there is potential for the new/add to be triggered twice
// if the add result happens slowly. This could be mitigated by adding some sort of "selection id"
// each time the guild changes. For our purposes so far, this "bug" should be OK to leave in.
// In the incredibly rare case where this does happen, you will see things duplicated
if (guild !== lastResult?.guild) return lastResult; // prevent changes from a different guild
if (!lastResult) { LOG.warn('got onNew with no lastResult'); return null; } // Sanity check
return { value: (lastResult.value ?? []).concat(newElements).sort(sortFunc), guild: guild };
});
}, [ guild, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return;
const updatedElements = updatedEventArgsMap(...args);
setLastResult((lastResult) => {
if (!lastResult) { LOG.warn('got onUpdated with no lastResult'); return null; } // Sanity check
return { value: (lastResult.value ?? []).map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc), guild: guild };
});
}, [ guild, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return;
const removedElements = removedEventArgsMap(...args);
setLastResult((lastResult) => {
if (!lastResult) { LOG.warn('got onRemoved with no lastResult'); return null; } // Sanity check
const deletedIds = new Set(removedElements.map(deletedElement => deletedElement.id));
return { value: (lastResult.value ?? []).filter(element => !deletedIds.has(element.id)), guild: guild };
});
}, [ guild, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMounted.current) return;
const changes = conflictEventArgsMap(...args);
setLastResult((lastResult) => {
if (!lastResult) { LOG.warn('got onConflict with no lastResult'); return null; } // Sanity check
const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id));
return {
value: (lastResult.value ?? [])
.concat(changes.added) // Added
.map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element) // Updated
.filter(element => !deletedIds.has(element.id)) // Deleted
.sort(sortFunc),
guild: guild
};
});
}, [ guild, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
const bindEventsFunc = useCallback(() => {
guild.on(newEventName, boundNewFunc);
guild.on(updatedEventName, boundUpdateFunc);
guild.on(removedEventName, boundRemovedFunc);
guild.on(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const unbindEventsFunc = useCallback(() => {
guild.off(newEventName, boundNewFunc);
guild.off(updatedEventName, boundUpdateFunc);
guild.off(removedEventName, boundRemovedFunc);
guild.off(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const [ fetchRetryCallable ] = useGuildSubscriptionEffect({
guild,
onFetch,
onFetchError,
bindEventsFunc,
unbindEventsFunc
}, fetchFunc);
return [ fetchRetryCallable, lastResult, fetchError ];
}
/**
* @param guild The current guild
* @param eventMappingParams The mappings to bind to guild events for updates, conflicts, etc
* @param maxElements The maximum number of elements to have loaded in the result list
* @param maxFetchElements The maximum number of elements to load in a single fetch
* @param fetchFunc The function to load the initial data
* @param fetchAboveFunc The function to load data above a reference element
* @param fetchBelowFunc The function to load data below a refrence element
* @param scrollToBottomFunc A function that will scroll the UI to the bottom (called when the data is first fetched)
* @returns [
* fetchRetryCallable: Can be called to re-fetch
* fetchAboveCallable: Call to fetch elements above the top element
* fetchBelowCallable: Call to fetch elements below the bottom element
* setScrollRatio: Call to set the position of the scrollbar. This is used to determine which elements to delete if a bunch of new elements are added.
* lastResult: The last result of the fetch (null = not fetched yet)
* fetchError: The error from the fetch
* fetchAboveError: The error from fetching above
* fetchBelowError: The error from fetching below
* ]
*/
function useMultipleGuildSubscriptionScrolling<
T extends { id: string },
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable
>(
guild: CombinedGuild,
eventMappingParams: MultipleEventMappingParams<T, NE, UE, RE, CE>,
maxElements: number,
maxFetchElements: number,
fetchFunc: () => Promise<T[] | null>,
fetchAboveFunc: ((reference: T) => Promise<T[] | null>),
fetchBelowFunc: ((reference: T) => Promise<T[] | null>),
scrollToBottomFunc: () => void,
): [
fetchRetryCallable: () => Promise<void>,
fetchAboveCallable: () => Promise<void>,
fetchBelowCallable: () => Promise<void>,
setScrollRatio: Dispatch<SetStateAction<number>>,
lastResult: ScrollingSubscriptionResult<T> | null,
fetchError: unknown | null,
fetchAboveError: unknown | null,
fetchBelowError: unknown | null,
] {
const {
newEventName, newEventArgsMap,
updatedEventName, updatedEventArgsMap,
removedEventName, removedEventArgsMap,
conflictEventName, conflictEventArgsMap,
sortFunc
} = eventMappingParams;
const isMounted = useIsMountedRef();
const guildRef = useRef<CombinedGuild>(guild);
guildRef.current = guild;
// TODO: lastResult.value should really be only T[] instead of | null since we set it to [] anyway in the onUpdate, etc functions
const [ lastResult, setLastResult ] = useState<ScrollingSubscriptionResult<T> | null>(null);
const [ fetchError, setFetchError ] = useState<unknown | null>(null);
const [ fetchAboveError, setFetchAboveError ] = useState<unknown | null>(null);
const [ fetchBelowError, setFetchBelowError ] = useState<unknown | null>(null);
// Percentage of scroll from top. i.e. 300px from top of 1000px scroll = 0.3 scroll ratio
const [ scrollRatio, setScrollRatio ] = useState<number>(0.5);
// Gets the number of elements to remove from the top or bottom. Tries to optimise so that if 10 elements
// are removed and we're 30% scrolled down, 3 get removed from the top and 7 are removed from the bottom.
// TBH, this function is pretty overkill but that's important
const getRemoveCounts = useCallback((toRemove: number): { fromTop: number, fromBottom: number } => {
// Make sure we round toward the side of the scrollbar that we are not closest to
// this is important for the most common case of 1 removed element.
const topRoundFunc = scrollRatio > 0.5 ? Math.ceil : Math.floor;
const bottomRoundFunc = scrollRatio > 0.5 ? Math.floor : Math.ceil;
return {
fromTop: topRoundFunc(toRemove * scrollRatio),
fromBottom: bottomRoundFunc(toRemove * scrollRatio)
2021-12-24 23:37:27 +00:00
}
}, [ scrollRatio ]);
function removeByCounts<T>(elements: T[], counts: { fromTop: number, fromBottom: number }): T[] {
const { fromTop, fromBottom } = counts;
return elements.slice(fromTop, elements.length - fromBottom);
}
2021-12-24 23:37:27 +00:00
const fetchAboveCallable = useCallback(async (): Promise<void> => {
if (!isMounted.current) return;
if (!lastResult || lastResult.value.elements.length === 0) return;
if (guild !== lastResult.guild) return;
try {
const reference = lastResult.value.elements[0] as T;
const aboveElements = await fetchAboveFunc(reference);
const lastResultAfterAwait = getStateAfterAwait(setLastResult);
if (!isMounted.current) return;
if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return;
setFetchAboveError(null);
if (aboveElements) {
const hasMoreAbove = aboveElements.length >= maxFetchElements;
let removedFromBottom = false;
setLastResult((lastResult) => {
if (!lastResult) return null;
let newElements = aboveElements.concat(lastResult.value.elements ?? []).sort(sortFunc);
if (newElements.length > maxElements) {
newElements = newElements.slice(0, maxElements);
removedFromBottom = true;
}
return {
value: {
elements: newElements,
ends: { hasMoreBelow: removedFromBottom || lastResult.value.ends.hasMoreBelow, hasMoreAbove }
},
guild: lastResult.guild
};
});
} else {
setLastResult((lastResult) => {
if (!lastResult) return null;
return {
value: {
elements: lastResult.value.elements,
ends: { hasMoreBelow: lastResult.value.ends.hasMoreBelow, hasMoreAbove: false }
},
guild: lastResult.guild
};
})
2021-12-24 23:37:27 +00:00
}
} catch (e: unknown) {
LOG.error('error fetching above for subscription', e);
const lastResultAfterAwait = getStateAfterAwait(setLastResult);
if (!isMounted.current) return;
if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return;
setFetchAboveError(e);
setLastResult((lastResult) => {
if (!lastResult) return null;
return {
value: {
elements: lastResult.value.elements,
ends: { hasMoreBelow: lastResult.value.ends.hasMoreBelow, hasMoreAbove: true },
},
guild: lastResult.guild
};
});
}
}, [ guild, lastResult, fetchAboveFunc, maxFetchElements ]);
const fetchBelowCallable = useCallback(async (): Promise<void> => {
if (!isMounted.current) return;
if (!lastResult || !lastResult.value || lastResult.value.elements.length === 0) return;
if (guild !== lastResult.guild) return;
try {
const reference = lastResult.value.elements[lastResult.value.elements.length - 1] as T;
const belowElements = await fetchBelowFunc(reference);
const lastResultAfterAwait = getStateAfterAwait(setLastResult);
if (!isMounted.current) return;
if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return;
setFetchBelowError(null);
if (belowElements) {
const hasMoreBelow = belowElements.length >= maxFetchElements;
let removedFromTop = false;
setLastResult((lastResult) => {
if (!lastResult) return null;
let newElements = (lastResult.value.elements ?? []).concat(belowElements).sort(sortFunc);
if (newElements.length > maxElements) {
newElements = newElements.slice(Math.max(newElements.length - maxElements, 0));
removedFromTop = true;
}
return {
value: {
elements: newElements,
ends: { hasMoreBelow, hasMoreAbove: removedFromTop || lastResult.value.ends.hasMoreAbove }
},
guild: lastResult.guild
};
});
} else {
setLastResult((lastResult) => {
if (!lastResult) return null;
return {
value: {
elements: lastResult.value.elements,
ends: { hasMoreBelow: false, hasMoreAbove: lastResult.value.ends.hasMoreAbove }
},
guild: lastResult.guild
};
});
2021-12-24 23:37:27 +00:00
}
} catch (e: unknown) {
LOG.error('error fetching below for subscription', e);
const lastResultAfterAwait = getStateAfterAwait(setLastResult);
if (!isMounted.current) return;
if (!lastResultAfterAwait || guild !== lastResultAfterAwait.guild) return;
setFetchBelowError(e);
setLastResult((lastResult) => {
if (!lastResult) return null;
return {
value: {
elements: lastResult.value.elements,
ends: { hasMoreBelow: true, hasMoreAbove: lastResult.value.ends.hasMoreAbove }
},
guild: lastResult.guild
};
});
}
}, [ lastResult, fetchBelowFunc, maxFetchElements ]);
2021-12-24 23:37:27 +00:00
const onFetch = useCallback((fetchValue: T[], fetchValueGuild: CombinedGuild) => {
let hasMoreAbove = false;
if (fetchValue) {
if (fetchValue.length >= maxFetchElements) hasMoreAbove = true;
fetchValue = fetchValue.slice(Math.max(fetchValue.length - maxElements)).sort(sortFunc);
}
//LOG.debug('Got items: ', { fetchValueLength: fetchValue?.length ?? '<empty>' })
setLastResult({
value: {
elements: fetchValue,
ends: { hasMoreAbove, hasMoreBelow: false }
},
guild: fetchValueGuild
});
setFetchError(null);
scrollToBottomFunc(); // Make sure that we scroll back to the bottom
}, [ sortFunc, maxFetchElements, maxElements ]);
const onFetchError = useCallback((e: unknown) => {
setFetchError(e);
setLastResult(null);
}, []);
// I think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls
const boundNewFunc = useCallback((...args: Arguments<Connectable[NE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes
const newElements = newEventArgsMap(...args);
setLastResult((lastResult) => {
if (lastResult === null) return null;
if (lastResult.value.ends.hasMoreBelow) return lastResult; // Don't add to bottom if we are not at the bottom
let newResultElements = (lastResult.value.elements ?? []).concat(newElements).sort(sortFunc);
let newEnds = lastResult.value.ends;
if (newResultElements.length > maxElements) {
// Remove in a way that tries to keep the scrollbar position consistent
const removeCounts = getRemoveCounts(newResultElements.length - maxElements);
newResultElements = removeByCounts(newResultElements, removeCounts);
newEnds = {
hasMoreBelow: removeCounts.fromBottom > 0 || lastResult.value.ends.hasMoreBelow,
hasMoreAbove: removeCounts.fromTop > 0 || lastResult.value.ends.hasMoreAbove
};
2021-12-24 23:37:27 +00:00
}
return {
value: {
elements: newResultElements,
ends: newEnds
},
guild: guild
};
});
}, [ guild, newEventArgsMap ]) as (Connectable & Conflictable)[NE];
const boundUpdateFunc = useCallback((...args: Arguments<Connectable[UE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes
const updatedElements = updatedEventArgsMap(...args);
setLastResult((lastResult) => {
if (lastResult === null) return null;
return {
value: {
elements: (lastResult.value.elements ?? [])
.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element)
.sort(sortFunc),
ends: lastResult.value.ends
},
guild: guild
};
});
}, [ guild, updatedEventArgsMap ]) as (Connectable & Conflictable)[UE];
const boundRemovedFunc = useCallback((...args: Arguments<Connectable[RE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes
const removedElements = removedEventArgsMap(...args);
setLastResult((lastResult) => {
if (lastResult === null) return null;
const deletedIds = new Set(removedElements.map(removedElement => removedElement.id));
return {
value: {
elements: (lastResult.value.elements ?? [])
.filter(element => !deletedIds.has(element.id)),
ends: lastResult.value.ends
},
guild: guild
};
});
}, [ guild, removedEventArgsMap ]) as (Connectable & Conflictable)[RE];
const boundConflictFunc = useCallback((...args: Arguments<Conflictable[CE]>): void => {
if (!isMounted.current) return;
if (guild !== lastResult?.guild) return; // Cancel calls when the guild changes
const changes = conflictEventArgsMap(...args);
setLastResult((lastResult) => {
if (lastResult === null) return null;
const deletedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id));
let newResultElements = (lastResult.value.elements ?? [])
.concat(changes.added)
.map(element => changes.updated.find(change => change.newDataPoint.id === element.id)?.newDataPoint ?? element)
.filter(element => !deletedIds.has(element.id))
.sort(sortFunc);
let newEnds = lastResult.value.ends;
if (newResultElements.length > maxElements) {
const removeCounts = getRemoveCounts(newResultElements.length - maxElements);
newResultElements = removeByCounts(newResultElements, removeCounts);
newEnds = {
hasMoreBelow: removeCounts.fromBottom > 0 || lastResult.value.ends.hasMoreBelow,
hasMoreAbove: removeCounts.fromTop > 0 || lastResult.value.ends.hasMoreAbove
};
}
return {
value: {
elements: newResultElements,
ends: newEnds
},
guild: guild
};
});
}, [ guild, conflictEventArgsMap ]) as (Connectable & Conflictable)[CE];
const bindEventsFunc = useCallback(() => {
guild.on(newEventName, boundNewFunc);
guild.on(updatedEventName, boundUpdateFunc);
guild.on(removedEventName, boundRemovedFunc);
guild.on(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const unbindEventsFunc = useCallback(() => {
guild.off(newEventName, boundNewFunc);
guild.off(updatedEventName, boundUpdateFunc);
guild.off(removedEventName, boundRemovedFunc);
guild.off(conflictEventName, boundConflictFunc);
}, [ boundNewFunc, boundUpdateFunc, boundRemovedFunc, boundConflictFunc ]);
const [ fetchRetryCallable ] = useGuildSubscriptionEffect({
guild,
onFetch,
onFetchError,
bindEventsFunc,
unbindEventsFunc
}, fetchFunc);
return [
fetchRetryCallable,
fetchAboveCallable,
fetchBelowCallable,
setScrollRatio,
lastResult,
fetchError,
fetchAboveError,
fetchBelowError,
];
}
2021-12-13 04:01:30 +00:00
/**
* @param guild The guild to load from
* @returns [
* guildMetaResult: The guild's metadata
* fetchError: Any error from fetching
* ]
*/
2022-01-28 03:46:40 +00:00
function useGuildMetadataSubscription(guild: CombinedGuild) {
const fetchMetadataFunc = useCallback(async () => {
//LOG.silly('fetching metadata for subscription');
return await guild.fetchMetadata();
}, [ guild ]);
return useSingleGuildSubscription<GuildMetadata, 'update-metadata', 'conflict-metadata'>(guild, {
updatedEventName: 'update-metadata',
updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta,
conflictEventName: 'conflict-metadata',
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, _oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta
}, fetchMetadataFunc);
}
2021-12-13 04:01:30 +00:00
/**
* @param guild The guild to load from
* @param resourceId The resource id to load from
* @param resourceIdGuild The guild associated with the resource id to load from (if this does not match the guild to load from, this will not update)
* @returns [
* resourceResult: The resource
* fetchError: Any error from fetching
* ]
*/
function useResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null) {
const fetchResourceFunc = useCallback(async () => {
//LOG.silly('fetching resource for subscription (resourceId: ' + resourceId + ')');
// Note: Returning null skips the load. This will prevent a null resourceResult
if (resourceId === null) return null;
if (resourceIdGuild === null) return null;
if (resourceIdGuild !== guild) return null;
const fetchResource = await guild.fetchResource(resourceId);
return fetchResource;
}, [ guild, resourceIdGuild, resourceId ]); // Explicitly do NOT want lastFetchResource since it would cause a re-fetch after fetching successfully
return useSingleGuildSubscription<Resource, 'update-resource', 'conflict-resource'>(guild, {
updatedEventName: 'update-resource',
updatedEventArgsMap: (resource: Resource) => resource,
conflictEventName: 'conflict-resource',
conflictEventArgsMap: (_query: IDQuery, _changesType: AutoVerifierChangesType, _oldResource: Resource, newResource: Resource) => newResource
}, fetchResourceFunc);
}
2021-12-13 05:25:23 +00:00
/**
* @param guild The guild to load from
* @param resourceId The resource id to load from
* @param resourceIdGuild The guild associated with the resource id to load from (if this does not match the guild to load from, this will not update)
* @returns [
* imgSrc: The image src (for use in an <img>)
* resourceResult: The resource
* fetchError: Any error from fetching
* ]
*/
2022-02-04 06:40:28 +00:00
function useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null): [
imgSrc: string,
resourceResult: SubscriptionResult<Resource> | null,
fetchError: unknown | null
] {
const [ resourceResult, fetchError ] = useResourceSubscription(guild, resourceId, resourceIdGuild);
2021-12-25 18:00:20 +00:00
const [ imgSrc ] = useOneTimeAsyncAction(
async () => {
//LOG.debug(`Fetching soft imgSrc for g#${guild.id} r#${resource?.id ?? '<null>'}`, { fetchError });
if (fetchError) return './img/error.png';
if (!resourceResult) return './img/loading.svg';
return await ElementsUtil.getImageSrcFromBufferFailSoftly(resourceResult.value.data);
},
'./img/loading.svg',
[ resourceResult, fetchError ]
);
2021-12-25 18:00:20 +00:00
return [ imgSrc, resourceResult, fetchError ];
}
/**
* @param guild The guild to load from
* @returns [
* channelsResult: The guild's channels
* fetchError: Any error from fetching
* ]
*/
function useChannelsSubscription(guild: CombinedGuild) {
const fetchChannelsFunc = useCallback(async () => {
return await guild.fetchChannels();
}, [ guild ]);
return useMultipleGuildSubscription<Channel, 'new-channels', 'update-channels', 'remove-channels', 'conflict-channels'>(guild, {
newEventName: 'new-channels',
newEventArgsMap: (channels: Channel[]) => channels,
updatedEventName: 'update-channels',
updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels,
removedEventName: 'remove-channels',
removedEventArgsMap: (removedChannels: Channel[]) => removedChannels,
conflictEventName: 'conflict-channels',
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes<Channel>) => changes,
sortFunc: Channel.sortByIndex
}, fetchChannelsFunc);
}
/**
* @param guild The guild to load from
* @returns [
* membersResult: The guild's members
* fetchError: Any error from fetching
* ]
*/
function useMembersSubscription(guild: CombinedGuild) {
const fetchMembersFunc = useCallback(async () => {
const members = await guild.fetchMembers();
return members;
//return await guild.fetchMembers();
}, [ guild ]);
return useMultipleGuildSubscription<Member, 'new-members', 'update-members', 'remove-members', 'conflict-members'>(guild, {
newEventName: 'new-members',
newEventArgsMap: (members: Member[]) => members,
updatedEventName: 'update-members',
updatedEventArgsMap: (updatedMembers: Member[]) => updatedMembers,
removedEventName: 'remove-members',
removedEventArgsMap: (removedMembers: Member[]) => removedMembers,
conflictEventName: 'conflict-members',
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes<Member>) => changes,
sortFunc: Member.sortForList
}, fetchMembersFunc);
}
2021-12-21 05:24:05 +00:00
/**
* @param guild The guild to load from
* @returns [
* selfMemberResult The guild's self member
* TODO: __fetchError: Any error from fetching
* ]
*/
function useSelfMemberSubscription(guild: CombinedGuild): [
selfMemberResult: SubscriptionResult<Member | null> | null
] {
const [ _fetchRetryCallable, membersResult, _fetchError ] = useMembersSubscription(guild);
// TODO: Show an error if we can't fetch and allow retry
const [ selfMemberResult, setSelfMemberResult ] = useState<SubscriptionResult<Member | null> | null>(null);
useEffect(() => {
if (isNonNullAndHasValue(membersResult) && membersResult.guild === guild) {
const member = membersResult.value.find(m => m.id === guild.memberId);
if (!member) {
LOG.warn('unable to find self in members');
setSelfMemberResult({ value: null, guild: membersResult.guild });
} else {
setSelfMemberResult({ value: member, guild: membersResult.guild });
2021-12-25 18:00:20 +00:00
}
}
}, [ membersResult ]);
2021-12-25 18:00:20 +00:00
return [ selfMemberResult ];
}
2021-12-25 18:00:20 +00:00
/**
* @param guild The guild to load from
* @returns [
* tokensResult: The guild's tokens
* fetchError: Any error from fetching
* ]
*/
function useTokensSubscription(guild: CombinedGuild) {
const fetchTokensFunc = useCallback(async () => {
//LOG.silly('fetching tokens for subscription');
return await guild.fetchTokens();
}, [ guild ]);
return useMultipleGuildSubscription<Token, 'new-tokens', 'update-tokens', 'remove-tokens', 'conflict-tokens'>(guild, {
newEventName: 'new-tokens',
newEventArgsMap: (tokens: Token[]) => tokens,
updatedEventName: 'update-tokens',
updatedEventArgsMap: (updatedTokens: Token[]) => updatedTokens,
removedEventName: 'remove-tokens',
removedEventArgsMap: (removedTokens: Token[]) => removedTokens,
conflictEventName: 'conflict-tokens',
conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes<Token>) => changes,
sortFunc: Token.sortRecentCreatedFirst
}, fetchTokensFunc);
}
2021-12-24 23:37:27 +00:00
/**
* @param guild The guild to load from
* @param channel The channel to load from
* @param channelGuild The guild that the channel belongs to
* @param scrollToBottomFunc A function that will be called when the UI should scroll to the bottom (when the messages are loaded for the first time for the channel)
* @returns [
* fetchRetryCallable: Can be called to re-fetch
* fetchAboveCallable: Call to fetch elements above the top element
* fetchBelowCallable: Call to fetch elements below the bottom element
* setScrollRatio: Call to set the position of the scrollbar. This is used to determine which elements to delete if a bunch of new elements are added.
* lastResult: The last result of the fetch (null = not fetched yet)
* fetchError: The error from the fetch
* fetchAboveError: The error from fetching above
* fetchBelowError: The error from fetching below
* ]
*/
2022-02-07 01:24:25 +00:00
function useMessagesScrollingSubscription(guild: CombinedGuild, channel: Channel, channelGuild: CombinedGuild, scrollToBottomFunc: () => void) {
const maxFetchElements = Globals.MESSAGES_PER_REQUEST;
const maxElements = Globals.MAX_CURRENT_MESSAGES;
const fetchMessagesFunc = useCallback(async () => {
if (guild !== channelGuild) {
return null; // Note: This skips the load so that we don't have to clear the message list
}
return await guild.fetchMessagesRecent(channel.id, maxFetchElements);
}, [ guild, channelGuild, channel.id, maxFetchElements ]);
const fetchAboveFunc = useCallback(async (reference: Message) => {
if (guild !== channelGuild) return [];
return await guild.fetchMessagesBefore(channel.id, reference._order, maxFetchElements);
}, [ guild, channelGuild, channel.id, maxFetchElements ]);
const fetchBelowFunc = useCallback(async (reference: Message) => {
if (guild !== channelGuild) return [];
return await guild.fetchMessagesAfter(channel.id, reference._order, maxFetchElements);
}, [ guild, channelGuild, channel.id, maxFetchElements ]);
return useMultipleGuildSubscriptionScrolling(
guild, {
newEventName: 'new-messages',
newEventArgsMap: (messages: Message[]) => messages,
updatedEventName: 'update-messages',
updatedEventArgsMap: (updatedMessages) => updatedMessages,
removedEventName: 'remove-messages',
removedEventArgsMap: (removedMessages) => removedMessages,
conflictEventName: 'conflict-messages',
conflictEventArgsMap: (_query: PartialMessageListQuery, _changesType: AutoVerifierChangesType, changes: Changes<Message>) => changes,
sortFunc: Message.sortOrder
},
maxElements, maxFetchElements,
fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc,
scrollToBottomFunc
);
2021-12-13 04:01:30 +00:00
}