styled error messages. bug where clicking to fetch above (and I assume below too) doesn't actually run anything

This commit is contained in:
Michael Peters 2022-02-12 13:23:10 -06:00
parent 73e81f0bc9
commit aa79034d7e
8 changed files with 460 additions and 146 deletions

View File

@ -12,7 +12,9 @@ $borderRadius: 8px;
flex-direction: column-reverse;
padding-bottom: calc(16px + 8px);
margin-bottom: calc(65px - $scrollbarBottom - $borderRadius);
height: calc(100vh - 71px - 65px + 4px + 8px); /* TODO: Going to have to find a way to do this without a fixed height since the message box needs to be scalable */
height: calc(
100vh - 71px - 65px + 4px + 8px
); /* TODO: Going to have to find a way to do this without a fixed height since the message box needs to be scalable */
overflow-y: scroll;
overflow-x: hidden;
@ -44,6 +46,32 @@ $borderRadius: 8px;
background-color: theme.$background-message-hover;
}
}
.retry {
display: flex;
justify-content: center;
align-items: center;
background-color: theme.$background-secondary-alt;
margin-left: 16px;
margin-right: 16px;
padding: 8px;
border-radius: 8px;
margin-top: 8px;
&:first-child {
margin-bottom: 8px;
}
.text {
margin-right: 8px;
color: theme.$text-normal;
}
.button {
font-size: 0.85em;
padding: 4px 8px;
}
}
}
}
}

View File

@ -1,4 +1,4 @@
import React, { MutableRefObject, ReactNode, RefObject, useCallback, useRef } from 'react';
import React, { MutableRefObject, ReactNode } from 'react';
import { LoadableValueScrolling } from '../require/loadables';
import { useScrollableCallables } from '../require/react-helper';
@ -23,6 +23,7 @@ function InfiniteScrollRecoil<T>(props: InfiniteScrollRecoilProps<T[], T>) {
<div className="infinite-scroll-elements">
<Retry error={scrollable?.above?.error} text={aboveErrorMessage} retryFunc={fetchAboveCallable} />
{children}
<Retry error={scrollable?.below?.error} text={belowErrorMessage} retryFunc={fetchBelowCallable} />
<Retry
error={scrollable.error}
text={initialErrorMessage}
@ -33,7 +34,6 @@ function InfiniteScrollRecoil<T>(props: InfiniteScrollRecoilProps<T[], T>) {
})
}
/>
<Retry error={scrollable?.below?.error} text={belowErrorMessage} retryFunc={fetchBelowCallable} />
</div>
</div>
);

View File

@ -23,9 +23,11 @@ const Retry: FC<RetryProps> = (props: RetryProps) => {
return { result: null, errorMessage: null };
},
[retryFunc],
{ start: 'Try Again', pending: 'Fetching...' },
{ start: 'Try Again', pending: 'Fetching...', done: 'Try Again', error: 'Try Again' },
);
// TODO: shaking isn't working
if (!error) return null;
return (

View File

@ -39,7 +39,11 @@ export type AtomEffectParam<T> = Arguments<AtomEffect<T>>[0];
// "initial" value loaders
export type FetchValueFunc = () => Promise<void>;
export function createFetchValueFunc<T>(atomEffectParam: AtomEffectParam<LoadableValue<T>>, guildId: number, fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>): FetchValueFunc {
export function createFetchValueFunc<T>(
atomEffectParam: AtomEffectParam<LoadableValue<T>>,
guildId: number,
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>,
): FetchValueFunc {
const { node, setSelf, getPromise } = atomEffectParam;
const fetchValueFunc = async () => {
const guild = await getPromise(guildState(guildId));
@ -53,8 +57,8 @@ export function createFetchValueFunc<T>(atomEffectParam: AtomEffectParam<Loadabl
const value = await fetchFunc(guild);
setSelf(createLoadedValue(value, fetchValueFunc));
} catch (e: unknown) {
LOG.error('unable to fetch initial guild metadata', e);
setSelf(createFailedValue(e, fetchValueFunc));
throw e;
}
};
return fetchValueFunc;
@ -84,10 +88,17 @@ export function createFetchValueScrollingFunc<T, E>(
setSelf(DEF_PENDED_SCROLLING_VALUE);
try {
const result = await fetchFunc(guild, count);
setSelf(createLoadedValueScrolling(result, fetchValueReferenceFunc, createAboveEndFunc(result), createBelowEndFunc(result)));
setSelf(
createLoadedValueScrolling(
result,
fetchValueReferenceFunc,
createAboveEndFunc(result),
createBelowEndFunc(result),
),
);
} catch (e: unknown) {
LOG.error('unable to fetch value scrolling', e);
setSelf(createFailedValueScrolling(e, fetchValueReferenceFunc));
throw e;
}
};
return fetchValueReferenceFunc;
@ -98,8 +109,15 @@ export function createFetchValueScrollingReferenceFunc<T>(
atomEffectParam: AtomEffectParam<LoadableValueScrolling<T[], T>>,
guildId: number,
getFunc: (selfState: LoadedValueScrolling<T[], T>) => LoadableScrollingEnd<T>,
applyEndToSelf: (selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => LoadedValueScrolling<T[], T>,
applyResultToSelf: (selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => LoadedValueScrolling<T[], T>,
applyEndToSelf: (
selfState: LoadedValueScrolling<T[], T>,
end: LoadableScrollingEnd<T>,
) => LoadedValueScrolling<T[], T>,
applyResultToSelf: (
selfState: LoadedValueScrolling<T[], T>,
end: LoadedScrollingEnd<T>,
result: T[],
) => LoadedValueScrolling<T[], T>,
count: number,
fetchReferenceFunc: (guild: CombinedGuild, reference: T, count: number) => Promise<T[]>,
): {
@ -134,15 +152,22 @@ export function createFetchValueScrollingReferenceFunc<T>(
setSelf(applyEndToSelf(selfState, createCancelledScrollingEnd(selfEnd)));
} else {
const hasMore = result.length >= count;
setSelf(applyResultToSelf(selfState, createLoadedScrollingEnd(hasMore, fetchValueReferenceFunc, cancel), result));
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);
LOG.error('unable to fetch value based on reference (but we were canceled anyway)', e);
setSelf(applyEndToSelf(selfState, createCancelledScrollingEnd(selfEnd)));
} else {
LOG.error('unable to fetch value based on reference', e);
// xLOG.error('unable to fetch value based on reference', e);
setSelf(applyEndToSelf(selfState, createFailedScrollingEnd(e, fetchValueReferenceFunc, cancel)));
throw e;
}
}
};
@ -156,7 +181,11 @@ function createEventHandler<
ArgsMapResult, // e.g. Member[]
// eslint-disable-next-line space-before-function-paren
XE extends keyof (Connectable | Conflictable), // e.g. new-members
>(atomEffectParam: AtomEffectParam<V>, argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, applyFunc: (selfState: V, argsResult: ArgsMapResult) => V): (Connectable & Conflictable)[XE] {
>(
atomEffectParam: AtomEffectParam<V>,
argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult,
applyFunc: (selfState: V, argsResult: ArgsMapResult) => V,
): (Connectable & Conflictable)[XE] {
const { node, setSelf, getPromise } = atomEffectParam;
// i think the typed EventEmitter class isn't ready for this level of insane type safety
// otherwise, I may have done this wrong. Forcing it to work with these calls
@ -198,8 +227,16 @@ export 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);
onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc);
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);
@ -212,7 +249,14 @@ export function listenToSingle<
return cleanup;
}
export interface MultipleEventMappingParams<T, V, NE extends keyof Connectable, UE extends keyof Connectable, RE extends keyof Connectable, CE extends keyof Conflictable> {
export interface MultipleEventMappingParams<
T,
V,
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable,
> {
newEvent: {
name: NE;
argsMap: (...args: Arguments<(Connectable & Conflictable)[NE]>) => Defined<T[]>;
@ -241,7 +285,11 @@ export function listenToMultiple<
UE extends keyof Connectable, // update Event
RE extends keyof Connectable, // remove Event
CE extends keyof Conflictable, // conflict Event
>(atomEffectParam: AtomEffectParam<V>, guildId: number, eventMapping: MultipleEventMappingParams<T, V, NE, UE, RE, CE>) {
>(
atomEffectParam: AtomEffectParam<V>,
guildId: number,
eventMapping: MultipleEventMappingParams<T, V, NE, UE, RE, CE>,
) {
const { getPromise } = atomEffectParam;
// listen for updates
let guild: CombinedGuild | null = null;
@ -256,9 +304,21 @@ export function listenToMultiple<
if (closed) return; // make sure not to bind events if this closed while we were fetching the guild state
onNewFunc = createEventHandler(atomEffectParam, eventMapping.newEvent.argsMap, eventMapping.newEvent.applyFunc);
onUpdateFunc = createEventHandler(atomEffectParam, eventMapping.updatedEvent.argsMap, eventMapping.updatedEvent.applyFunc);
onRemoveFunc = createEventHandler(atomEffectParam, eventMapping.removedEvent.argsMap, eventMapping.removedEvent.applyFunc);
onConflictFunc = createEventHandler(atomEffectParam, eventMapping.conflictEvent.argsMap, eventMapping.conflictEvent.applyFunc);
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);
@ -279,7 +339,12 @@ export function guildDataSubscriptionLoadableSingleEffect<
T, // e.g. GuildMetadata
UE extends keyof Connectable, // update Event
CE extends keyof Conflictable, // conflict Event
>(guildId: number, fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>, eventMapping: SingleEventMappingParams<T, LoadableValue<T>, UE, CE>, skipFunc?: () => boolean) {
>(
guildId: number,
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T>>,
eventMapping: SingleEventMappingParams<T, LoadableValue<T>, UE, CE>,
skipFunc?: () => boolean,
) {
const effect: AtomEffect<LoadableValue<T>> = (atomEffectParam: AtomEffectParam<LoadableValue<T>>) => {
const { trigger } = atomEffectParam;
if (skipFunc && skipFunc()) return; // don't run if this atom should be skipped for some reason (e.g. null resourceId)
@ -288,7 +353,13 @@ export function guildDataSubscriptionLoadableSingleEffect<
// fetch initial value on first get
if (trigger === 'get') {
fetchValueFunc();
(async () => {
try {
await fetchValueFunc();
} catch (e: unknown) {
LOG.error('error fetching initial value', e);
}
})();
}
// listen to changes
@ -301,7 +372,13 @@ export function guildDataSubscriptionLoadableSingleEffect<
return effect;
}
export function guildDataSubscriptionLoadableMultipleEffect<T extends { id: string }, NE extends keyof Connectable, UE extends keyof Connectable, RE extends keyof Connectable, CE extends keyof Conflictable>(
export function guildDataSubscriptionLoadableMultipleEffect<
T extends { id: string },
NE extends keyof Connectable,
UE extends keyof Connectable,
RE extends keyof Connectable,
CE extends keyof Conflictable,
>(
guildId: number,
fetchFunc: (guild: CombinedGuild) => Promise<Defined<T[]>>,
eventMapping: MultipleEventMappingParams<T, LoadableValue<T[]>, NE, UE, RE, CE>,
@ -313,7 +390,13 @@ export function guildDataSubscriptionLoadableMultipleEffect<T extends { id: stri
// fetch initial value on first get
if (trigger === 'get') {
fetchValueFunc();
(async () => {
try {
await fetchValueFunc();
} catch (e: unknown) {
LOG.error('error fetching initial value', e);
}
})();
}
// listen to changes
@ -355,70 +438,84 @@ export function multipleScrollingGuildSubscriptionEffect<
guildId,
fetchCount,
fetchFuncs.fetchBottomFunc,
(result: T[]) => createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), // above end
(result: T[]) =>
createLoadedScrollingEnd(result.length >= fetchCount, fetchValueAboveReferenceFunc, cancelAbove), // above end
(_result: T[]) => createLoadedScrollingEnd(false, fetchValueBelowReferenceFunc, cancelBelow), // below end
);
// fetch Above a Reference
const { fetchValueReferenceFunc: fetchValueAboveReferenceFunc, cancel: cancelAbove } = createFetchValueScrollingReferenceFunc<T>(
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.above,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({ ...selfState, above: end }), // for "cancelled, pending, etc"
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => {
let nextValue = result.concat(selfState.value).sort(sortFunc);
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(undefined, maxElements);
cancelBelow();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
const { fetchValueReferenceFunc: fetchValueAboveReferenceFunc, cancel: cancelAbove } =
createFetchValueScrollingReferenceFunc<T>(
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.above,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({
...selfState,
value: nextValue,
above: end,
below: {
...selfState.below,
hasMore: sliced ? true : selfState.below.hasMore,
} as LoadableScrollingEnd<T>, // 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,
);
}), // for "cancelled, pending, etc"
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => {
let nextValue = result.concat(selfState.value).sort(sortFunc);
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(undefined, maxElements);
cancelBelow();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
...selfState,
value: nextValue,
above: end,
below: {
...selfState.below,
hasMore: sliced ? true : selfState.below.hasMore,
} as LoadableScrollingEnd<T>, // 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<T>(
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.below,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({ ...selfState, below: end }),
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => {
let nextValue = result.concat(selfState.value).sort(sortFunc);
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(nextValue.length - maxElements, undefined);
cancelAbove();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
const { fetchValueReferenceFunc: fetchValueBelowReferenceFunc, cancel: cancelBelow } =
createFetchValueScrollingReferenceFunc<T>(
atomEffectParam,
guildId,
(selfState: LoadedValueScrolling<T[], T>) => selfState.below,
(selfState: LoadedValueScrolling<T[], T>, end: LoadableScrollingEnd<T>) => ({
...selfState,
value: nextValue,
above: {
...selfState.above,
hasMore: sliced ? true : selfState.above.hasMore,
} as LoadableScrollingEnd<T>,
below: end,
};
return loadedValue;
},
fetchCount,
fetchFuncs.fetchBelowFunc,
);
}),
(selfState: LoadedValueScrolling<T[], T>, end: LoadedScrollingEnd<T>, result: T[]) => {
let nextValue = result.concat(selfState.value).sort(sortFunc);
let sliced = false;
if (nextValue.length > maxElements) {
nextValue = nextValue.slice(nextValue.length - maxElements, undefined);
cancelAbove();
sliced = true;
}
const loadedValue: LoadedValueScrolling<T[], T> = {
...selfState,
value: nextValue,
above: {
...selfState.above,
hasMore: sliced ? true : selfState.above.hasMore,
} as LoadableScrollingEnd<T>,
below: end,
};
return loadedValue;
},
fetchCount,
fetchFuncs.fetchBelowFunc,
);
// fetch bottom value on first get
if (trigger === 'get') {
LOG.debug('fetching scrolling bottom...');
fetchValueBottomFunc();
(async () => {
try {
await fetchValueBottomFunc();
} catch (e: unknown) {
LOG.error('error fetching bottom value', e);
}
})();
}
// listen to changes
@ -434,19 +531,33 @@ export function multipleScrollingGuildSubscriptionEffect<
export function applyNewElements<T>(list: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] {
return list.concat(newElements).sort(sortFunc);
}
export function applyUpdatedElements<T extends { id: string }>(list: T[], updatedElements: T[], sortFunc: (a: T, b: T) => number): T[] {
return list.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc);
export function applyUpdatedElements<T extends { id: string }>(
list: T[],
updatedElements: T[],
sortFunc: (a: T, b: T) => number,
): T[] {
return list
.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element)
.sort(sortFunc);
}
export function applyRemovedElements<T extends { id: string }>(list: T[], removedElements: T[]): T[] {
const removedIds = new Set<string>(removedElements.map(removedElement => removedElement.id));
return list.filter(element => !removedIds.has(element.id));
}
export function applyChangedElements<T extends { id: string }>(list: T[], changes: Changes<T>, sortFunc: (a: T, b: T) => number): T[] {
export function applyChangedElements<T extends { id: string }>(
list: T[],
changes: Changes<T>,
sortFunc: (a: T, b: T) => number,
): T[] {
const removedIds = new Set<string>(changes.deleted.map(deletedElement => deletedElement.id));
return list
.concat(changes.added)
.map(element => changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? element)
.map(
element =>
changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ??
element,
)
.filter(element => !removedIds.has(element.id))
.sort(sortFunc);
}

View File

@ -102,6 +102,7 @@ export default class ElementsUtil {
return src;
} catch (e) {
LOG.warn('unable to fetch and convert guild resource, showing error instead', e);
//xLOG.warn('unable to fetch and convert guild resource, showing error instead', e);
return './img/error.png';
}
}

View File

@ -3,7 +3,20 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import { DependencyList, Dispatch, MutableRefObject, ReactNode, RefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DependencyList,
Dispatch,
MutableRefObject,
ReactNode,
RefObject,
SetStateAction,
UIEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ShouldNeverHappenError } from '../../data-types';
import Util from '../../util';
import Globals from '../../globals';
@ -46,7 +59,11 @@ function useShake(ms: number): [shaking: boolean, doShake: () => void] {
}
// runs an Async action one time (updates when deps changes)
export function useOneTimeAsyncAction<T, V>(actionFunc: () => Promise<T>, initialValue: V, deps: DependencyList): [value: T | V, error: unknown | null] {
export function useOneTimeAsyncAction<T, V>(
actionFunc: () => Promise<T>,
initialValue: V,
deps: DependencyList,
): [value: T | V, error: unknown | null] {
const isMounted = useRef(false);
const [value, setValue] = useState<T | V>(initialValue);
@ -79,9 +96,11 @@ export function useOneTimeAsyncAction<T, V>(actionFunc: () => Promise<T>, initia
/** creates a callable async function that will not double-trigger and gives a result, error message, and shaking boolean */
export function useAsyncCallback<ResultType>(
actionFunc: (isMounted: MutableRefObject<boolean>) => Promise<{ errorMessage: string | null; result: ResultType | null }>,
actionFunc: (
isMounted: MutableRefObject<boolean>,
) => Promise<{ errorMessage: string | null; result: ResultType | null }>,
deps: DependencyList,
): [callable: () => void, result: ResultType | null, errorMessage: string | null, shaking: boolean] {
): [callable: () => void, result: ResultType | null, errorMessage: string | null, shaking: boolean, pending: boolean] {
const isMounted = useIsMountedRef();
const [result, setResult] = useState<ResultType | null>(null);
@ -103,20 +122,23 @@ export function useAsyncCallback<ResultType>(
setPending(false);
if (errorMessage) doShake();
} catch (e: unknown) {
LOG.error('unable to perform submit button actionFunc', e);
LOG.error('unable to perform async callback', e);
setResult(null);
setErrorMessage('Unknown error');
if (errorMessage) doShake();
setErrorMessage('unknown error');
doShake();
setPending(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, pending, actionFunc, doShake, errorMessage, ...deps]);
return [callable, result, errorMessage, shaking];
return [callable, result, errorMessage, shaking, pending];
}
/** creates a callable async function that does not need to return a value (or shake) */
export function useAsyncVoidCallback(actionFunc: (isMounted: MutableRefObject<boolean>) => Promise<void>, deps: DependencyList): [callable: () => void] {
export function useAsyncVoidCallback(
actionFunc: (isMounted: MutableRefObject<boolean>) => Promise<void>,
deps: DependencyList,
): [callable: () => void] {
const [callable] = useAsyncCallback(async (isMounted: MutableRefObject<boolean>) => {
await actionFunc(isMounted);
return { errorMessage: null, result: null };
@ -129,9 +151,28 @@ export function useDownloadButton(
downloadName: string,
fetchBuff: () => Promise<Buffer | null>,
fetchBuffDeps: DependencyList,
stateTextMapping?: { start?: string; pendingFetch?: string; errorFetch?: string; notReadyFetch?: string; pendingSave?: string; errorSave?: string; success?: string },
stateTextMapping?: {
start?: string;
pendingFetch?: string;
errorFetch?: string;
notReadyFetch?: string;
pendingSave?: string;
errorSave?: string;
success?: string;
},
): [callable: () => void, text: string, shaking: boolean] {
const textMapping = { ...{ start: 'Download', pendingFetch: 'Downloading...', errorFetch: 'Try Again', notReadyFetch: 'Not Ready', pendingSave: 'Saving...', errorSave: 'Try Again', success: 'Open in Explorer' }, ...stateTextMapping };
const textMapping = {
...{
start: 'Download',
pendingFetch: 'Downloading...',
errorFetch: 'Try Again',
notReadyFetch: 'Not Ready',
pendingSave: 'Saving...',
errorSave: 'Try Again',
success: 'Open in Explorer',
},
...stateTextMapping,
};
const [filePath, setFilePath] = useState<string | null>(null);
const [fileBuffer, setFileBuffer] = useState<Buffer | null>(null);
@ -207,30 +248,25 @@ export function useDownloadButton(
/** creates useful state variables for a button intended call a submit action */
export function useAsyncSubmitButton<ResultType>(
actionFunc: (isMounted: MutableRefObject<boolean>) => Promise<{ errorMessage: string | null; result: ResultType | null }>,
actionFunc: (
isMounted: MutableRefObject<boolean>,
) => Promise<{ errorMessage: string | null; result: ResultType | null }>,
deps: DependencyList,
stateTextMapping?: { start?: string; pending?: string; error?: string; done?: string },
): [callable: () => void, text: string, shaking: boolean, result: ResultType | null, errorMessage: string | null] {
const textMapping = { ...{ start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, ...stateTextMapping };
const textMapping = {
...{ start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' },
...stateTextMapping,
};
const [pending, setPending] = useState<boolean>(false);
const [complete, setComplete] = useState<boolean>(false);
const [callable, result, errorMessage, shaking] = useAsyncCallback(
const [callable, result, errorMessage, shaking, pending] = useAsyncCallback(
async (isMounted: MutableRefObject<boolean>) => {
try {
setPending(true);
const actionReturnValue = await actionFunc(isMounted);
if (!isMounted) return { errorMessage: null, result: null };
setPending(false);
if (actionReturnValue.errorMessage === null) setComplete(true);
return actionReturnValue;
} catch (e) {
LOG.error('Error submitting button', e);
if (!isMounted) return { errorMessage: null, result: null };
setPending(false);
return { errorMessage: 'Error submitting button', result: null };
}
const actionReturnValue = await actionFunc(isMounted);
if (!isMounted) return { errorMessage: null, result: null };
if (actionReturnValue.errorMessage === null) setComplete(true);
return actionReturnValue;
},
[...deps, actionFunc],
);
@ -325,9 +361,15 @@ export function useScrollableCallables<T>(
// makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside
/** calls the close action when you hit escape or click outside of the ref element */
export function useActionWhenEscapeOrClickedOrContextOutsideEffect(ref: RefObject<HTMLElement>, actionFunc: () => void) {
export function useActionWhenEscapeOrClickedOrContextOutsideEffect(
ref: RefObject<HTMLElement>,
actionFunc: () => void,
) {
// have to use a ref here and not states since we can't re-assign state between mouseup and click
const mouseRef = useRef<{ mouseDownTarget: Node | null; mouseUpTarget: Node | null }>({ mouseDownTarget: null, mouseUpTarget: null });
const mouseRef = useRef<{ mouseDownTarget: Node | null; mouseUpTarget: Node | null }>({
mouseDownTarget: null,
mouseUpTarget: null,
});
const handleClickOutside = useCallback(
(event: MouseEvent) => {
@ -453,7 +495,10 @@ export function useAlignment(
}
/** creates useful context menu state */
export function useContextMenu(createContextMenu: (close: () => void) => ReactNode, createContextMenuDeps: DependencyList): [contextMenu: ReactNode, toggle: () => void, close: () => void, open: () => void, isOpen: boolean] {
export function useContextMenu(
createContextMenu: (close: () => void) => ReactNode,
createContextMenuDeps: DependencyList,
): [contextMenu: ReactNode, toggle: () => void, close: () => void, open: () => void, isOpen: boolean] {
const [isOpen, setIsOpen] = useState<boolean>(false);
const close = useCallback(() => {
@ -482,9 +527,15 @@ export function useContextClickContextMenu(
const alignment = useMemo(() => ({ top: 'centerY', left: 'centerX' }), []);
const createContextMenuWithRelativeToPos = useCallback((close: () => void) => createContextMenu(alignment, relativeToPos, close), [alignment, createContextMenu, relativeToPos]);
const createContextMenuWithRelativeToPos = useCallback(
(close: () => void) => createContextMenu(alignment, relativeToPos, close),
[alignment, createContextMenu, relativeToPos],
);
const [contextMenu, _toggle, _close, open] = useContextMenu(createContextMenuWithRelativeToPos, createContextMenuDeps);
const [contextMenu, _toggle, _close, open] = useContextMenu(
createContextMenuWithRelativeToPos,
createContextMenuDeps,
);
const onContextMenu = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
@ -498,7 +549,10 @@ export function useContextClickContextMenu(
}
/** creates context state for hovering over an element */
export function useContextHover(createContextHover: () => ReactNode, createContextHoverDeps: DependencyList): [contextHover: ReactNode, mouseEnterCallable: () => void, mouseLeaveCallable: () => void] {
export function useContextHover(
createContextHover: () => ReactNode,
createContextHoverDeps: DependencyList,
): [contextHover: ReactNode, mouseEnterCallable: () => void, mouseLeaveCallable: () => void] {
const [isOpen, setIsOpen] = useState<boolean>(false);
const contextHover = useMemo(
@ -518,7 +572,11 @@ export function useContextHover(createContextHover: () => ReactNode, createConte
}
/** creates a drop target element that will appear when you drag a file over the document */
export function useDocumentDropTarget(message: string, setBuff: Dispatch<SetStateAction<Buffer | null>>, setName: Dispatch<SetStateAction<string | null>>): [dropTarget: ReactNode] {
export function useDocumentDropTarget(
message: string,
setBuff: Dispatch<SetStateAction<Buffer | null>>,
setName: Dispatch<SetStateAction<string | null>>,
): [dropTarget: ReactNode] {
const [dragEnabled, setDragEnabled] = useState<boolean>(false);
// drag overlay

View File

@ -7,7 +7,18 @@ import * as socketio from 'socket.io-client';
import PersonalDBGuild from './guild-personal-db';
import RAMGuild from './guild-ram';
import SocketGuild from './guild-socket';
import { Changes, Channel, ConnectionInfo, GuildMetadata, GuildMetadataLocal, Member, Message, Resource, SocketConfig, Token } from './data-types';
import {
Changes,
Channel,
ConnectionInfo,
GuildMetadata,
GuildMetadataLocal,
Member,
Message,
Resource,
SocketConfig,
Token,
} from './data-types';
import MessageRAMCache from './message-ram-cache';
import PersonalDB from './personal-db';
@ -19,9 +30,13 @@ import EnsuredFetchable from './fetchable-ensured';
import { EventEmitter } from 'tsee';
import { AutoVerifierChangesType } from './auto-verifier';
import { IDQuery, PartialMessageListQuery } from './auto-verifier-with-args';
import Util from './util';
/** the general guild class. This handles connecting a RAM, PersonalDB, and Socket guild together using fetchable-pair-verifiers and some manual caching to nicely update messages */
export default class CombinedGuild extends EventEmitter<Connectable & Conflictable> implements AsyncGuaranteedFetchable, AsyncRequestable {
export default class CombinedGuild
extends EventEmitter<Connectable & Conflictable>
implements AsyncGuaranteedFetchable, AsyncRequestable
{
private readonly ramGuild: RAMGuild;
private readonly personalDBGuild: PersonalDBGuild;
private readonly socketGuild: SocketGuild;
@ -29,7 +44,15 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
private readonly fetchable: AsyncGuaranteedFetchable;
private readonly mainPairVerifier: PairVerifierFetchable;
constructor(public readonly id: number, public readonly memberId: string, socket: socketio.Socket, socketVerifier: SocketVerifier, messageRAMCache: MessageRAMCache, resourceRAMCache: ResourceRAMCache, personalDB: PersonalDB) {
constructor(
public readonly id: number,
public readonly memberId: string,
socket: socketio.Socket,
socketVerifier: SocketVerifier,
messageRAMCache: MessageRAMCache,
resourceRAMCache: ResourceRAMCache,
personalDB: PersonalDB,
) {
super();
this.ramGuild = new RAMGuild(messageRAMCache, resourceRAMCache, this.id);
@ -102,8 +125,18 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
for (const message of messages) {
LOG.info(`g#${this.id} ${message}`);
}
this.ramGuild.handleMessagesAdded(messages, diskSocket.fetchMessagesRecentVerifier, diskSocket.fetchMessagesBeforeVerifier, diskSocket.fetchMessagesAfterVerifier);
await this.personalDBGuild.handleMessagesAdded(messages, diskSocket.fetchMessagesRecentVerifier, diskSocket.fetchMessagesBeforeVerifier, diskSocket.fetchMessagesAfterVerifier);
this.ramGuild.handleMessagesAdded(
messages,
diskSocket.fetchMessagesRecentVerifier,
diskSocket.fetchMessagesBeforeVerifier,
diskSocket.fetchMessagesAfterVerifier,
);
await this.personalDBGuild.handleMessagesAdded(
messages,
diskSocket.fetchMessagesRecentVerifier,
diskSocket.fetchMessagesBeforeVerifier,
diskSocket.fetchMessagesAfterVerifier,
);
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
for (const message of messages) message.fill(members, channels);
@ -113,8 +146,18 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
for (const message of messages) {
LOG.info(`g#${this.id} updated ${message}`);
}
this.ramGuild.handleMessagesChanged(messages, ramDiskSocket.fetchMessagesRecentVerifier, ramDiskSocket.fetchMessagesBeforeVerifier, ramDiskSocket.fetchMessagesAfterVerifier);
await this.personalDBGuild.handleMessagesChanged(messages, diskSocket.fetchMessagesRecentVerifier, diskSocket.fetchMessagesBeforeVerifier, diskSocket.fetchMessagesAfterVerifier);
this.ramGuild.handleMessagesChanged(
messages,
ramDiskSocket.fetchMessagesRecentVerifier,
ramDiskSocket.fetchMessagesBeforeVerifier,
ramDiskSocket.fetchMessagesAfterVerifier,
);
await this.personalDBGuild.handleMessagesChanged(
messages,
diskSocket.fetchMessagesRecentVerifier,
diskSocket.fetchMessagesBeforeVerifier,
diskSocket.fetchMessagesAfterVerifier,
);
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
for (const message of messages) message.fill(members, channels);
@ -156,10 +199,13 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
});
// forward the conflict events from the last verifier in the chain
ramDiskSocket.on('conflict-metadata', (changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => {
LOG.info(`g#${this.id} metadata conflict`, { oldGuildMeta, newGuildMeta });
this.emit('conflict-metadata', changesType, oldGuildMeta, newGuildMeta);
});
ramDiskSocket.on(
'conflict-metadata',
(changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => {
LOG.info(`g#${this.id} metadata conflict`, { oldGuildMeta, newGuildMeta });
this.emit('conflict-metadata', changesType, oldGuildMeta, newGuildMeta);
},
);
ramDiskSocket.on('conflict-members', (changesType: AutoVerifierChangesType, changes: Changes<Member>) => {
LOG.info(`g#${this.id} members conflict`); // this should be pretty common since we manually update the status to "unknown" which will cause a conflict
this.emit('conflict-members', changesType, changes);
@ -168,28 +214,40 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
LOG.info(`g#${this.id} channels conflict`, { changes });
this.emit('conflict-channels', changesType, changes);
});
ramDiskSocket.on('conflict-messages', async (query: PartialMessageListQuery, changesType: AutoVerifierChangesType, changes: Changes<Message>) => {
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
for (const message of changes.added) message.fill(members, channels);
for (const dataPoint of changes.updated) dataPoint.newDataPoint.fill(members, channels);
for (const message of changes.deleted) message.fill(members, channels);
this.emit('conflict-messages', query, changesType, changes);
});
ramDiskSocket.on(
'conflict-messages',
async (query: PartialMessageListQuery, changesType: AutoVerifierChangesType, changes: Changes<Message>) => {
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
for (const message of changes.added) message.fill(members, channels);
for (const dataPoint of changes.updated) dataPoint.newDataPoint.fill(members, channels);
for (const message of changes.deleted) message.fill(members, channels);
this.emit('conflict-messages', query, changesType, changes);
},
);
ramDiskSocket.on('conflict-tokens', (changesType: AutoVerifierChangesType, changes: Changes<Token>) => {
LOG.info(`g#${this.id} tokens conflict`, { changes });
this.emit('conflict-tokens', changesType, changes);
});
ramDiskSocket.on('conflict-resource', (query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => {
LOG.warn(`g#${this.id} resource conflict`, { oldResource, newResource });
this.emit('conflict-resource', query, changesType, oldResource, newResource);
});
ramDiskSocket.on(
'conflict-resource',
(query: IDQuery, changesType: AutoVerifierChangesType, oldResource: Resource, newResource: Resource) => {
LOG.warn(`g#${this.id} resource conflict`, { oldResource, newResource });
this.emit('conflict-resource', query, changesType, oldResource, newResource);
},
);
this.fetchable = new EnsuredFetchable(ramDiskSocket);
this.mainPairVerifier = ramDiskSocket;
}
static async create(guildMetadata: GuildMetadataLocal, socketConfig: SocketConfig, messageRAMCache: MessageRAMCache, resourceRAMCache: ResourceRAMCache, personalDB: PersonalDB) {
static async create(
guildMetadata: GuildMetadataLocal,
socketConfig: SocketConfig,
messageRAMCache: MessageRAMCache,
resourceRAMCache: ResourceRAMCache,
personalDB: PersonalDB,
) {
if (guildMetadata.memberId === null) {
throw new Error('trying to launch guild that we have never verified with');
}
@ -198,7 +256,15 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
ca: socketConfig.cert,
});
const socketVerifier = new SocketVerifier(socket, socketConfig.publicKey, socketConfig.privateKey);
return new CombinedGuild(guildMetadata.id, guildMetadata.memberId, socket, socketVerifier, messageRAMCache, resourceRAMCache, personalDB);
return new CombinedGuild(
guildMetadata.id,
guildMetadata.memberId,
socket,
socketVerifier,
messageRAMCache,
resourceRAMCache,
personalDB,
);
}
public isSocketVerified(): boolean {
@ -220,7 +286,8 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
if (this.ramGuild.getChannels().size === 0) {
await this.fetchChannels();
}
if (this.ramGuild.getChannels().size === 0) throw new Error('RAM Channels was not updated through fetchChannels');
if (this.ramGuild.getChannels().size === 0)
throw new Error('RAM Channels was not updated through fetchChannels');
}
async grabRAMMembersMap(): Promise<Map<string, Member>> {
@ -261,7 +328,11 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
connection.rolePriority = member.rolePriority;
connection.privileges = member.privileges;
} else {
LOG.warn(`g#${this.id} unable to find self in members (of ${members.size}, [${Array.from(members.keys()).join(',')}], ${member}, ${this.memberId})`);
LOG.warn(
`g#${this.id} unable to find self in members (of ${members.size}, [${Array.from(
members.keys(),
).join(',')}], ${member}, ${this.memberId})`,
);
}
} else {
const members = await this.personalDBGuild.fetchMembers();
@ -283,15 +354,19 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
// fetched through the triple-cache system (RAM -> Disk -> Server)
async fetchMetadata(): Promise<GuildMetadata> {
// xUtil.failSometimes(0.05); // for testing
return await this.fetchable.fetchMetadata();
}
async fetchMembers(): Promise<Member[]> {
// xUtil.failSometimes(0.05); // for testing
return await this.fetchable.fetchMembers();
}
async fetchChannels(): Promise<Channel[]> {
// xUtil.failSometimes(0.05); // for testing
return await this.fetchable.fetchChannels();
}
async fetchMessagesRecent(channelId: string, number: number): Promise<Message[]> {
// xUtil.failSometimes(0.05); // for testing
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
const messages = await this.fetchable.fetchMessagesRecent(channelId, number);
@ -301,6 +376,7 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
return messages;
}
async fetchMessagesBefore(channelId: string, messageOrderId: string, number: number): Promise<Message[]> {
Util.failSometimes(0.5); // for testing
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
const messages = await this.fetchable.fetchMessagesBefore(channelId, messageOrderId, number);
@ -310,6 +386,7 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
return messages;
}
async fetchMessagesAfter(channelId: string, messageOrderId: string, number: number): Promise<Message[]> {
// xUtil.failSometimes(0.05); // for testing
const members = await this.grabRAMMembersMap();
const channels = await this.grabRAMChannelsMap();
const messages = await this.fetchable.fetchMessagesAfter(channelId, messageOrderId, number);
@ -319,9 +396,11 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
return messages;
}
async fetchResource(resourceId: string): Promise<Resource> {
// xUtil.failSometimes(0.05); // for testing
return await this.fetchable.fetchResource(resourceId);
}
async fetchTokens(): Promise<Token[]> {
// xUtil.failSometimes(0.05); // for testing
const members = await this.grabRAMMembersMap();
const tokens = await this.fetchable.fetchTokens();
for (const token of tokens) {
@ -335,7 +414,12 @@ export default class CombinedGuild extends EventEmitter<Connectable & Conflictab
await this.socketGuild.requestSendMessage(channelId, text);
}
// TODO: Change to "withAttachment"
async requestSendMessageWithResource(channelId: string, text: string | null, resource: Buffer, resourceName: string): Promise<void> {
async requestSendMessageWithResource(
channelId: string,
text: string | null,
resource: Buffer,
resourceName: string,
): Promise<void> {
await this.socketGuild.requestSendMessageWithResource(channelId, text, resource, resourceName);
}
async requestSetStatus(status: string): Promise<void> {

View File

@ -32,7 +32,32 @@ export default class Util {
// solution: Replace reserved characters with empty string (''), bad characters with '_', and append '_' to bad names
// illegal File Names (Windows)
if (['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'].indexOf(name) !== -1) {
if (
[
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
].indexOf(name) !== -1
) {
// TODO: case insensitive?
name += '_';
}
@ -72,6 +97,11 @@ export default class Util {
});
}
/** throws an error based on a ratio from 0-1 */
static failSometimes(ratio: number): void {
if (Math.random() < ratio) throw new Error('fail sometimes');
}
// this function is a promise for error stack tracking purposes.
// this function expects the last argument of args to be a callback(err, serverData)
static socketEmitTimeout(