From aa79034d7e5fa110dc45ab72927b364916bad9ca Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Sat, 12 Feb 2022 13:23:10 -0600 Subject: [PATCH] styled error messages. bug where clicking to fetch above (and I assume below too) doesn't actually run anything --- .../elements-styles/lists/message-list.scss | 30 +- .../elements/components/infinite-scroll.tsx | 4 +- .../webapp/elements/components/retry.tsx | 4 +- .../webapp/elements/require/atoms-funcs.ts | 267 +++++++++++++----- .../webapp/elements/require/elements-util.tsx | 1 + .../webapp/elements/require/react-helper.tsx | 128 ++++++--- src/client/webapp/guild-combined.ts | 140 +++++++-- src/client/webapp/util.ts | 32 ++- 8 files changed, 460 insertions(+), 146 deletions(-) diff --git a/src/client/webapp/elements-styles/lists/message-list.scss b/src/client/webapp/elements-styles/lists/message-list.scss index 5a532da..c2bbe3d 100644 --- a/src/client/webapp/elements-styles/lists/message-list.scss +++ b/src/client/webapp/elements-styles/lists/message-list.scss @@ -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; + } + } } } } diff --git a/src/client/webapp/elements/components/infinite-scroll.tsx b/src/client/webapp/elements/components/infinite-scroll.tsx index 3179c73..3df96cc 100644 --- a/src/client/webapp/elements/components/infinite-scroll.tsx +++ b/src/client/webapp/elements/components/infinite-scroll.tsx @@ -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(props: InfiniteScrollRecoilProps) {
{children} + (props: InfiniteScrollRecoilProps) { }) } /> -
); diff --git a/src/client/webapp/elements/components/retry.tsx b/src/client/webapp/elements/components/retry.tsx index 77750e6..7b544f0 100644 --- a/src/client/webapp/elements/components/retry.tsx +++ b/src/client/webapp/elements/components/retry.tsx @@ -23,9 +23,11 @@ const Retry: FC = (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 ( diff --git a/src/client/webapp/elements/require/atoms-funcs.ts b/src/client/webapp/elements/require/atoms-funcs.ts index d7035b6..067ceef 100644 --- a/src/client/webapp/elements/require/atoms-funcs.ts +++ b/src/client/webapp/elements/require/atoms-funcs.ts @@ -39,7 +39,11 @@ export type AtomEffectParam = Arguments>[0]; // "initial" value loaders export type FetchValueFunc = () => Promise; -export function createFetchValueFunc(atomEffectParam: AtomEffectParam>, guildId: number, fetchFunc: (guild: CombinedGuild) => Promise>): FetchValueFunc { +export function createFetchValueFunc( + atomEffectParam: AtomEffectParam>, + guildId: number, + fetchFunc: (guild: CombinedGuild) => Promise>, +): FetchValueFunc { const { node, setSelf, getPromise } = atomEffectParam; const fetchValueFunc = async () => { const guild = await getPromise(guildState(guildId)); @@ -53,8 +57,8 @@ export function createFetchValueFunc(atomEffectParam: AtomEffectParam( 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( atomEffectParam: AtomEffectParam>, guildId: number, getFunc: (selfState: LoadedValueScrolling) => LoadableScrollingEnd, - applyEndToSelf: (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => LoadedValueScrolling, - applyResultToSelf: (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => LoadedValueScrolling, + applyEndToSelf: ( + selfState: LoadedValueScrolling, + end: LoadableScrollingEnd, + ) => LoadedValueScrolling, + applyResultToSelf: ( + selfState: LoadedValueScrolling, + end: LoadedScrollingEnd, + result: T[], + ) => LoadedValueScrolling, count: number, fetchReferenceFunc: (guild: CombinedGuild, reference: T, count: number) => Promise, ): { @@ -134,15 +152,22 @@ export function createFetchValueScrollingReferenceFunc( 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, argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, applyFunc: (selfState: V, argsResult: ArgsMapResult) => V): (Connectable & Conflictable)[XE] { +>( + atomEffectParam: AtomEffectParam, + argsMap: (...args: Arguments<(Connectable & Conflictable)[XE]>) => ArgsMapResult, + applyFunc: (selfState: V, argsResult: ArgsMapResult) => V, +): (Connectable & Conflictable)[XE] { const { node, setSelf, getPromise } = atomEffectParam; // i think the typed EventEmitter class isn't ready for this level of insane type safety // otherwise, I may have done this wrong. Forcing it to work with these calls @@ -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 { +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; @@ -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, guildId: number, eventMapping: MultipleEventMappingParams) { +>( + atomEffectParam: AtomEffectParam, + guildId: number, + eventMapping: MultipleEventMappingParams, +) { 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>, eventMapping: SingleEventMappingParams, UE, CE>, skipFunc?: () => boolean) { +>( + guildId: number, + fetchFunc: (guild: CombinedGuild) => Promise>, + eventMapping: SingleEventMappingParams, UE, CE>, + skipFunc?: () => boolean, +) { const effect: AtomEffect> = (atomEffectParam: AtomEffectParam>) => { const { trigger } = atomEffectParam; if (skipFunc && skipFunc()) return; // don't run if this atom should be skipped for some reason (e.g. null resourceId) @@ -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( +export function guildDataSubscriptionLoadableMultipleEffect< + T extends { id: string }, + NE extends keyof Connectable, + UE extends keyof Connectable, + RE extends keyof Connectable, + CE extends keyof Conflictable, +>( guildId: number, fetchFunc: (guild: CombinedGuild) => Promise>, eventMapping: MultipleEventMappingParams, NE, UE, RE, CE>, @@ -313,7 +390,13 @@ export function guildDataSubscriptionLoadableMultipleEffect { + 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( - atomEffectParam, - guildId, - (selfState: LoadedValueScrolling) => selfState.above, - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, above: end }), // for "cancelled, pending, etc" - (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { - let nextValue = result.concat(selfState.value).sort(sortFunc); - let sliced = false; - if (nextValue.length > maxElements) { - nextValue = nextValue.slice(undefined, maxElements); - cancelBelow(); - sliced = true; - } - const loadedValue: LoadedValueScrolling = { + const { fetchValueReferenceFunc: fetchValueAboveReferenceFunc, cancel: cancelAbove } = + createFetchValueScrollingReferenceFunc( + atomEffectParam, + guildId, + (selfState: LoadedValueScrolling) => selfState.above, + (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, - value: nextValue, above: end, - below: { - ...selfState.below, - hasMore: sliced ? true : selfState.below.hasMore, - } as LoadableScrollingEnd, // this is OK since selfState.below is already a LoadableScrollingEnd and we are only modifying hasMore to potentially include a boolean - }; - return loadedValue; - }, - fetchCount, - fetchFuncs.fetchAboveFunc, - ); + }), // for "cancelled, pending, etc" + (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { + let nextValue = result.concat(selfState.value).sort(sortFunc); + let sliced = false; + if (nextValue.length > maxElements) { + nextValue = nextValue.slice(undefined, maxElements); + cancelBelow(); + sliced = true; + } + const loadedValue: LoadedValueScrolling = { + ...selfState, + value: nextValue, + above: end, + below: { + ...selfState.below, + hasMore: sliced ? true : selfState.below.hasMore, + } as LoadableScrollingEnd, // this is OK since selfState.below is already a LoadableScrollingEnd and we are only modifying hasMore to potentially include a boolean + }; + return loadedValue; + }, + fetchCount, + fetchFuncs.fetchAboveFunc, + ); // fetch Below a Reference - const { fetchValueReferenceFunc: fetchValueBelowReferenceFunc, cancel: cancelBelow } = createFetchValueScrollingReferenceFunc( - atomEffectParam, - guildId, - (selfState: LoadedValueScrolling) => selfState.below, - (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, below: end }), - (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { - let nextValue = result.concat(selfState.value).sort(sortFunc); - let sliced = false; - if (nextValue.length > maxElements) { - nextValue = nextValue.slice(nextValue.length - maxElements, undefined); - cancelAbove(); - sliced = true; - } - const loadedValue: LoadedValueScrolling = { + const { fetchValueReferenceFunc: fetchValueBelowReferenceFunc, cancel: cancelBelow } = + createFetchValueScrollingReferenceFunc( + atomEffectParam, + guildId, + (selfState: LoadedValueScrolling) => selfState.below, + (selfState: LoadedValueScrolling, end: LoadableScrollingEnd) => ({ ...selfState, - value: nextValue, - above: { - ...selfState.above, - hasMore: sliced ? true : selfState.above.hasMore, - } as LoadableScrollingEnd, below: end, - }; - return loadedValue; - }, - fetchCount, - fetchFuncs.fetchBelowFunc, - ); + }), + (selfState: LoadedValueScrolling, end: LoadedScrollingEnd, result: T[]) => { + let nextValue = result.concat(selfState.value).sort(sortFunc); + let sliced = false; + if (nextValue.length > maxElements) { + nextValue = nextValue.slice(nextValue.length - maxElements, undefined); + cancelAbove(); + sliced = true; + } + const loadedValue: LoadedValueScrolling = { + ...selfState, + value: nextValue, + above: { + ...selfState.above, + hasMore: sliced ? true : selfState.above.hasMore, + } as LoadableScrollingEnd, + below: end, + }; + return loadedValue; + }, + fetchCount, + fetchFuncs.fetchBelowFunc, + ); // fetch bottom value on first get if (trigger === 'get') { - LOG.debug('fetching scrolling bottom...'); - fetchValueBottomFunc(); + (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(list: T[], newElements: T[], sortFunc: (a: T, b: T) => number): T[] { return list.concat(newElements).sort(sortFunc); } -export function applyUpdatedElements(list: T[], updatedElements: T[], sortFunc: (a: T, b: T) => number): T[] { - return list.map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element).sort(sortFunc); +export function applyUpdatedElements( + list: T[], + updatedElements: T[], + sortFunc: (a: T, b: T) => number, +): T[] { + return list + .map(element => updatedElements.find(updatedElement => updatedElement.id === element.id) ?? element) + .sort(sortFunc); } export function applyRemovedElements(list: T[], removedElements: T[]): T[] { const removedIds = new Set(removedElements.map(removedElement => removedElement.id)); return list.filter(element => !removedIds.has(element.id)); } -export function applyChangedElements(list: T[], changes: Changes, sortFunc: (a: T, b: T) => number): T[] { +export function applyChangedElements( + list: T[], + changes: Changes, + sortFunc: (a: T, b: T) => number, +): T[] { const removedIds = new Set(changes.deleted.map(deletedElement => deletedElement.id)); return list .concat(changes.added) - .map(element => changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? element) + .map( + element => + changes.updated.find(updatedPair => updatedPair.newDataPoint.id === element.id)?.newDataPoint ?? + element, + ) .filter(element => !removedIds.has(element.id)) .sort(sortFunc); } diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index 53c3bf7..3f21c6d 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -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'; } } diff --git a/src/client/webapp/elements/require/react-helper.tsx b/src/client/webapp/elements/require/react-helper.tsx index eb4e3db..fdfab80 100644 --- a/src/client/webapp/elements/require/react-helper.tsx +++ b/src/client/webapp/elements/require/react-helper.tsx @@ -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(actionFunc: () => Promise, initialValue: V, deps: DependencyList): [value: T | V, error: unknown | null] { +export function useOneTimeAsyncAction( + actionFunc: () => Promise, + initialValue: V, + deps: DependencyList, +): [value: T | V, error: unknown | null] { const isMounted = useRef(false); const [value, setValue] = useState(initialValue); @@ -79,9 +96,11 @@ export function useOneTimeAsyncAction(actionFunc: () => Promise, initia /** creates a callable async function that will not double-trigger and gives a result, error message, and shaking boolean */ export function useAsyncCallback( - actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null; result: ResultType | null }>, + actionFunc: ( + isMounted: MutableRefObject, + ) => 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(null); @@ -103,20 +122,23 @@ export function useAsyncCallback( 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) => Promise, deps: DependencyList): [callable: () => void] { +export function useAsyncVoidCallback( + actionFunc: (isMounted: MutableRefObject) => Promise, + deps: DependencyList, +): [callable: () => void] { const [callable] = useAsyncCallback(async (isMounted: MutableRefObject) => { await actionFunc(isMounted); return { errorMessage: null, result: null }; @@ -129,9 +151,28 @@ export function useDownloadButton( downloadName: string, fetchBuff: () => Promise, 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(null); const [fileBuffer, setFileBuffer] = useState(null); @@ -207,30 +248,25 @@ export function useDownloadButton( /** creates useful state variables for a button intended call a submit action */ export function useAsyncSubmitButton( - actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null; result: ResultType | null }>, + actionFunc: ( + isMounted: MutableRefObject, + ) => 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(false); const [complete, setComplete] = useState(false); - const [callable, result, errorMessage, shaking] = useAsyncCallback( + const [callable, result, errorMessage, shaking, pending] = useAsyncCallback( async (isMounted: MutableRefObject) => { - 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( // 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, actionFunc: () => void) { +export function useActionWhenEscapeOrClickedOrContextOutsideEffect( + ref: RefObject, + 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(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) => { @@ -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(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>, setName: Dispatch>): [dropTarget: ReactNode] { +export function useDocumentDropTarget( + message: string, + setBuff: Dispatch>, + setName: Dispatch>, +): [dropTarget: ReactNode] { const [dragEnabled, setDragEnabled] = useState(false); // drag overlay diff --git a/src/client/webapp/guild-combined.ts b/src/client/webapp/guild-combined.ts index 107953e..9375ca0 100644 --- a/src/client/webapp/guild-combined.ts +++ b/src/client/webapp/guild-combined.ts @@ -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 implements AsyncGuaranteedFetchable, AsyncRequestable { +export default class CombinedGuild + extends EventEmitter + 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 { - 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) => { 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) => { - 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) => { + 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) => { 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> { @@ -261,7 +328,11 @@ export default class CombinedGuild extends EventEmitter Disk -> Server) async fetchMetadata(): Promise { + // xUtil.failSometimes(0.05); // for testing return await this.fetchable.fetchMetadata(); } async fetchMembers(): Promise { + // xUtil.failSometimes(0.05); // for testing return await this.fetchable.fetchMembers(); } async fetchChannels(): Promise { + // xUtil.failSometimes(0.05); // for testing return await this.fetchable.fetchChannels(); } async fetchMessagesRecent(channelId: string, number: number): Promise { + // 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 { + 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 { + // 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 { + // xUtil.failSometimes(0.05); // for testing return await this.fetchable.fetchResource(resourceId); } async fetchTokens(): Promise { + // 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 { + async requestSendMessageWithResource( + channelId: string, + text: string | null, + resource: Buffer, + resourceName: string, + ): Promise { await this.socketGuild.requestSendMessageWithResource(channelId, text, resource, resourceName); } async requestSetStatus(status: string): Promise { diff --git a/src/client/webapp/util.ts b/src/client/webapp/util.ts index c7e4c60..bb99960 100644 --- a/src/client/webapp/util.ts +++ b/src/client/webapp/util.ts @@ -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(