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 { Changes, GuildMetadata, Member, Message, Resource } from "../../data-types"; import CombinedGuild from "../../guild-combined"; import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; import { AutoVerifierChangesType } from "../../auto-verifier"; import { Conflictable, Connectable } from "../../guild-types"; import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args'; import { Token, Channel } from '../../data-types'; import { useIsMountedRef, useOneTimeAsyncAction } from './react-helper'; import Globals from '../../globals'; import ElementsUtil from './elements-util'; // Abuse closures to get state in case it changed after an await call // TODO: This function may be useless... function getStateAfterAwait(setState: Dispatch>): T { let x: unknown; setState(state => { x = state; return state; }); return x as T; } // Parameters used by base guildSubscription to fetch the initial value interface EffectParams { 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; onFetchError: (e: unknown) => void; bindEventsFunc: () => void; unbindEventsFunc: () => void; } // General typescript type that infers the arguments of a function type Arguments = T extends (...args: infer A) => unknown ? A : never; // The result of a general subscription. Includes the value and associated guild interface SubscriptionResult { 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 interface ScrollingSubscriptionResult { value: { ends: { hasMoreAbove: boolean, hasMoreBelow: boolean }; elements: T[]; }, guild: CombinedGuild; } // Ensures that a nullable subscriptionResult has a value and has been fetched function isNonNullAndHasValue(subscriptionResult: SubscriptionResult | null): subscriptionResult is SubscriptionResult { return !!(subscriptionResult !== null && subscriptionResult.value !== null); } // Maps a "single" data subscription's events (like guild-metadata) interface SingleEventMappingParams { updatedEventName: UE; updatedEventArgsMap: (...args: Arguments) => T; conflictEventName: CE; conflictEventArgsMap: (...args: Arguments) => T; } // Maps a "multiple" data subscription's events (like channels) // This data subscription can be incrementally updated interface MultipleEventMappingParams< T, NE extends keyof Connectable, UE extends keyof Connectable, RE extends keyof Connectable, CE extends keyof Conflictable > { newEventName: NE; newEventArgsMap: (...args: Arguments) => T[]; // list of new elements updatedEventName: UE; updatedEventArgsMap: (...args: Arguments) => T[]; // list of updated elements removedEventName: RE; removedEventArgsMap: (...args: Arguments) => T[]; // list of removed elements conflictEventName: CE; conflictEventArgsMap: (...args: Arguments) => Changes; sortFunc: (a: T, b: T) => number; // Friendly reminder that v8 uses timsort so this is O(n) for pre-sorted stuff } /** * 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( subscriptionParams: EffectParams, fetchFunc: () => Promise ): [ fetchRetryCallable: () => Promise ] { const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; const isMounted = useIsMountedRef(); const guildRef = useRef(guild); guildRef.current = guild; const fetchManagerFunc = useCallback(async () => { if (!isMounted.current) return; if (guildRef.current !== guild) return; try { const value = await fetchFunc(); 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); if (!isMounted.current) return; if (guildRef.current !== guild) return; onFetchError(e); } }, [ fetchFunc ]); useEffect(() => { // Bind guild events to make sure we have the most up to date information guild.on('connect', fetchManagerFunc); guild.on('disconnect', fetchManagerFunc); bindEventsFunc(); // 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 ]); 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( guild: CombinedGuild, eventMappingParams: SingleEventMappingParams, fetchFunc: () => Promise ): [lastResult: SubscriptionResult | null, fetchError: unknown | null] { const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; const isMounted = useIsMountedRef(); const [ fetchError, setFetchError ] = useState(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): 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): 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, fetchFunc: () => Promise ): [ fetchRetryCallable: () => Promise, lastResult: SubscriptionResult | null, fetchError: unknown | null, ] { const { newEventName, newEventArgsMap, updatedEventName, updatedEventArgsMap, removedEventName, removedEventArgsMap, conflictEventName, conflictEventArgsMap, sortFunc } = eventMappingParams; const isMounted = useIsMountedRef(); const [ fetchError, setFetchError ] = useState(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): 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): 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): 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): 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, maxElements: number, maxFetchElements: number, fetchFunc: () => Promise, fetchAboveFunc: ((reference: T) => Promise), fetchBelowFunc: ((reference: T) => Promise), scrollToBottomFunc: () => void, ): [ fetchRetryCallable: () => Promise, fetchAboveCallable: () => Promise, fetchBelowCallable: () => Promise, setScrollRatio: Dispatch>, lastResult: ScrollingSubscriptionResult | 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(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 | null>(null); const [ fetchError, setFetchError ] = useState(null); const [ fetchAboveError, setFetchAboveError ] = useState(null); const [ fetchBelowError, setFetchBelowError ] = useState(null); // Percentage of scroll from top. i.e. 300px from top of 1000px scroll = 0.3 scroll ratio const [ scrollRatio, setScrollRatio ] = useState(0.5); // Gets the number of elements to remove from the top or bottom. Tries to optimise so that if 10 elements // are removed and we're 30% scrolled down, 3 get removed from the top and 7 are removed from the bottom. // TBH, this function is pretty overkill but that's important const getRemoveCounts = useCallback((toRemove: number): { fromTop: number, fromBottom: number } => { // Make sure we round toward the side of the scrollbar that we are not closest to // this is important for the most common case of 1 removed element. const topRoundFunc = scrollRatio > 0.5 ? Math.ceil : Math.floor; const bottomRoundFunc = scrollRatio > 0.5 ? Math.floor : Math.ceil; return { fromTop: topRoundFunc(toRemove * scrollRatio), fromBottom: bottomRoundFunc(toRemove * scrollRatio) } }, [ scrollRatio ]); function removeByCounts(elements: T[], counts: { fromTop: number, fromBottom: number }): T[] { const { fromTop, fromBottom } = counts; return elements.slice(fromTop, elements.length - fromBottom); } const fetchAboveCallable = useCallback(async (): Promise => { 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 }; }) } } 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 => { 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 }; }); } } 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 ]); 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 ?? '' }) 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): 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 }; } return { value: { elements: newResultElements, ends: newEnds }, guild: guild }; }); }, [ guild, newEventArgsMap ]) as (Connectable & Conflictable)[NE]; const boundUpdateFunc = useCallback((...args: Arguments): 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): 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): 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, ]; } /** * @param guild The guild to load from * @returns [ * guildMetaResult: The guild's metadata * fetchError: Any error from fetching * ] */ function useGuildMetadataSubscription(guild: CombinedGuild) { const fetchMetadataFunc = useCallback(async () => { //LOG.silly('fetching metadata for subscription'); return await guild.fetchMetadata(); }, [ guild ]); return useSingleGuildSubscription(guild, { updatedEventName: 'update-metadata', updatedEventArgsMap: (guildMeta: GuildMetadata) => guildMeta, conflictEventName: 'conflict-metadata', conflictEventArgsMap: (_changesType: AutoVerifierChangesType, _oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => newGuildMeta }, fetchMetadataFunc); } /** * @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(guild, { updatedEventName: 'update-resource', updatedEventArgsMap: (resource: Resource) => resource, conflictEventName: 'conflict-resource', conflictEventArgsMap: (_query: IDQuery, _changesType: AutoVerifierChangesType, _oldResource: Resource, newResource: Resource) => newResource }, fetchResourceFunc); } /** * @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 ) * resourceResult: The resource * fetchError: Any error from fetching * ] */ function useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null, resourceIdGuild: CombinedGuild | null): [ imgSrc: string, resourceResult: SubscriptionResult | null, fetchError: unknown | null ] { const [ resourceResult, fetchError ] = useResourceSubscription(guild, resourceId, resourceIdGuild); const [ imgSrc ] = useOneTimeAsyncAction( async () => { //LOG.debug(`Fetching soft imgSrc for g#${guild.id} r#${resource?.id ?? ''}`, { 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 ] ); 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(guild, { newEventName: 'new-channels', newEventArgsMap: (channels: Channel[]) => channels, updatedEventName: 'update-channels', updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels, removedEventName: 'remove-channels', removedEventArgsMap: (removedChannels: Channel[]) => removedChannels, conflictEventName: 'conflict-channels', conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Channel.sortByIndex }, fetchChannelsFunc); } /** * @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(guild, { newEventName: 'new-members', newEventArgsMap: (members: Member[]) => members, updatedEventName: 'update-members', updatedEventArgsMap: (updatedMembers: Member[]) => updatedMembers, removedEventName: 'remove-members', removedEventArgsMap: (removedMembers: Member[]) => removedMembers, conflictEventName: 'conflict-members', conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Member.sortForList }, fetchMembersFunc); } /** * @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 | null ] { const [ _fetchRetryCallable, membersResult, _fetchError ] = useMembersSubscription(guild); // TODO: Show an error if we can't fetch and allow retry const [ selfMemberResult, setSelfMemberResult ] = useState | 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 }); } } }, [ membersResult ]); return [ selfMemberResult ]; } /** * @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(guild, { newEventName: 'new-tokens', newEventArgsMap: (tokens: Token[]) => tokens, updatedEventName: 'update-tokens', updatedEventArgsMap: (updatedTokens: Token[]) => updatedTokens, removedEventName: 'remove-tokens', removedEventArgsMap: (removedTokens: Token[]) => removedTokens, conflictEventName: 'conflict-tokens', conflictEventArgsMap: (_changesType: AutoVerifierChangesType, changes: Changes) => changes, sortFunc: Token.sortRecentCreatedFirst }, fetchTokensFunc); } /** * @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 * ] */ 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) => changes, sortFunc: Message.sortOrder }, maxElements, maxFetchElements, fetchMessagesFunc, fetchAboveFunc, fetchBelowFunc, scrollToBottomFunc ); }