diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index f124a1f..936f3cf 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -29,7 +29,7 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi const [ tokens, tokensError ] = GuildSubscriptions.useTokensSubscription(guild); const [ guildMeta, guildMetaError ] = GuildSubscriptions.useGuildMetadataSubscription(guild); - const [ iconSrc ] = ReactHelper.useAsyncActionSubscription( + const [ iconSrc ] = ReactHelper.useOneTimeAsyncAction( async () => await ElementsUtil.getImageSrcFromResourceFailSoftly(guild, guildMeta?.iconResourceId ?? null), './img/loading.svg', [ guild, guildMeta?.iconResourceId ] diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index 79864f7..cf9ee94 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { createRef, FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import GuildsManager from '../../guilds-manager'; import moment from 'moment'; import TextInput from '../components/input-text'; @@ -15,7 +15,7 @@ import UI from '../../ui'; import CombinedGuild from '../../guild-combined'; import ElementsUtil from '../require/elements-util'; import InvitePreview from '../components/invite-preview'; -import ReactHelper, { ExpectedError } from '../require/react-helper'; +import ReactHelper from '../require/react-helper'; import * as fs from 'fs/promises'; import Button from '../components/button'; @@ -60,8 +60,6 @@ export interface AddGuildOverlayProps { const AddGuildOverlay: FC = (props: AddGuildOverlayProps) => { const { document, ui, guildsManager, addGuildData } = props; - const isMounted = ReactHelper.useIsMountedRef(); - const expired = addGuildData.expires < new Date().getTime(); const exampleDisplayName = useMemo(() => getExampleDisplayName(), []); const exampleAvatarPath = useMemo(() => getExampleAvatarPath(), []); @@ -69,15 +67,13 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) const [ displayName, setDisplayName ] = useState(exampleDisplayName); const [ avatarBuff, setAvatarBuff ] = useState(null); - const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState(null); - const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState(null); const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); const [ avatarInputValid, setAvatarInputValid ] = useState(false); - const [ exampleAvatarBuff, exampleAvatarBuffError ] = ReactHelper.useAsyncActionSubscription( + const [ exampleAvatarBuff, exampleAvatarBuffError ] = ReactHelper.useOneTimeAsyncAction( async () => await fs.readFile(exampleAvatarPath), null, [ exampleAvatarPath ] @@ -94,48 +90,41 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; return null; - }, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]); + }, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); - const [ _, submitError, submitButtonText, submitButtonShaking, submitButtonCallback ] = ReactHelper.useAsyncButtonSubscription(async (): Promise => { - if (validationErrorMessage || !avatarBuff) throw new ExpectedError('invalid input'); - - if (expired) { - setAddGuildFailedMessage('token expired'); - throw new ExpectedError('token expired'); - } - - let newGuild: CombinedGuild; - try { - newGuild = await guildsManager.addNewGuild(addGuildData, displayName, avatarBuff); - if (!isMounted.current) return; - setAddGuildFailedMessage(null); - } catch (e: unknown) { - if (!isMounted.current) return; - LOG.error('error adding new guild', e); - if (e instanceof Error) { - setAddGuildFailedMessage(e.message); - } else { - setAddGuildFailedMessage('error adding new guild'); + const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = ReactHelper.useSubmitButton( + async () => { + if (validationErrorMessage || !avatarBuff) return { result: null, errorMessage: 'Validation failed' }; + if (expired) return { result: null, errorMessage: 'Token expired' }; + + let newGuild: CombinedGuild; + try { + newGuild = await guildsManager.addNewGuild(addGuildData, displayName, avatarBuff); + } catch (e: unknown) { + LOG.error('error adding new guild', e); + return { result: null, errorMessage: 'Error adding new guild' }; } - throw e; - } - const guildElement = await ui.addGuild(guildsManager, newGuild); - ElementsUtil.closeReactOverlay(document); - guildElement.click(); - }, { start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ]); + const guildElement = await ui.addGuild(guildsManager, newGuild); + ElementsUtil.closeReactOverlay(document); + guildElement.click(); + + return { result: newGuild, errorMessage: null }; + }, + [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ] + ); const errorMessage = useMemo(() => { if (validationErrorMessage) return validationErrorMessage; - if (addGuildFailedMessage) return addGuildFailedMessage; + if (submitFailMessage) return submitFailMessage; return null; - }, [ validationErrorMessage, addGuildFailedMessage ]) + }, [ validationErrorMessage, submitFailMessage ]); return (
@@ -152,12 +141,12 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) maxLength={Globals.MAX_DISPLAY_NAME_LENGTH} value={displayName} setValue={setDisplayName} setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage} - onEnterKeyDown={submitButtonCallback} + onEnterKeyDown={submitFunc} />
- + ); diff --git a/src/client/webapp/elements/overlays/overlay-channel.tsx b/src/client/webapp/elements/overlays/overlay-channel.tsx index 63f554e..ee10863 100644 --- a/src/client/webapp/elements/overlays/overlay-channel.tsx +++ b/src/client/webapp/elements/overlays/overlay-channel.tsx @@ -11,7 +11,7 @@ import SubmitOverlayLower from '../components/submit-overlay-lower'; import Globals from '../../globals'; import ElementsUtil from '../require/elements-util'; import { Channel } from '../../data-types'; -import ReactHelper, { ExpectedError } from '../require/react-helper'; +import ReactHelper from '../require/react-helper'; import Button from '../components/button'; export interface ChannelOverlayProps { @@ -22,11 +22,7 @@ export interface ChannelOverlayProps { const ChannelOverlay: FC = (props: ChannelOverlayProps) => { const { document, guild, channel } = props; - const isMounted = ReactHelper.useIsMountedRef(); - const nameInputRef = createRef(); - const flavorTextInputRef = createRef(); - const submitButtonRef = createRef(); const [ edited, setEdited ] = useState(false); @@ -39,8 +35,6 @@ const ChannelOverlay: FC = (props: ChannelOverlayProps) => const [ flavorTextInputValid, setFlavorTextInputValid ] = useState(false); const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState(null); - const [ submitFailMessage, setSubmitFailMessage ] = useState(null); - useEffect(() => { nameInputRef.current?.focus(); }, []); @@ -66,37 +60,39 @@ const ChannelOverlay: FC = (props: ChannelOverlayProps) => return null; }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); - const [ _, submitError, submitButtonText, submitButtonShaking, submit ] = ReactHelper.useAsyncButtonSubscription(async (): Promise => { - if (validationErrorMessage) throw new ExpectedError('invalid input'); - setSubmitFailMessage(null); + const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = ReactHelper.useSubmitButton( + async () => { + if (validationErrorMessage) return { result: null, errorMessage: 'Invalid input' }; - if (!edited) { - ElementsUtil.closeReactOverlay(document); - return; - } - - try { - // Make sure to null out flavor text if empty - if (channel) { - await guild.requestDoUpdateChannel(channel.id, name, flavorText === '' ? null : flavorText); - } else { - await guild.requestDoCreateChannel(name, flavorText === '' ? null : flavorText); + if (!edited) { + ElementsUtil.closeReactOverlay(document); + return { result: null, errorMessage: null }; } - if (!isMounted.current) return; - } catch (e: unknown) { - if (!isMounted.current) return; - setSubmitFailMessage('Error submitting'); - throw e; - } - - ElementsUtil.closeReactOverlay(document); - }, { start: channel ? 'Modify' : 'Create', pending: 'Submitting...', error: 'Error', done: 'Done' }, [ edited, validationErrorMessage, name, flavorText ]); + + try { + // Make sure to null out flavor text if empty + if (channel) { + await guild.requestDoUpdateChannel(channel.id, name, flavorText === '' ? null : flavorText); + } else { + await guild.requestDoCreateChannel(name, flavorText === '' ? null : flavorText); + } + } catch (e: unknown) { + LOG.error(`Error ${channel ? 'updating' : 'creating'} channel`, e); + return { result: null, errorMessage: `Error ${channel ? 'updating' : 'creating'} channel`} + } + + ElementsUtil.closeReactOverlay(document); + return { result: null, errorMessage: null }; + }, + [ edited, validationErrorMessage, name, flavorText ], + { start: channel ? 'Modify Channel' : 'Create Channel' } + ); const errorMessage = useMemo(() => { if (validationErrorMessage) return validationErrorMessage; - if (submitError) return 'Unable to modify channel'; + if (submitFailMessage) return submitFailMessage; return null; - }, [ validationErrorMessage, submitError ]); + }, [ validationErrorMessage, submitFailMessage ]); return (
@@ -114,22 +110,21 @@ const ChannelOverlay: FC = (props: ChannelOverlayProps) => value={name} setValue={setName} setValid={setNameInputValid} setMessage={setNameInputMessage} valueMap={value => value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')} - onEnterKeyDown={() => flavorTextInputRef.current?.focus()} + onEnterKeyDown={submitFunc} />
- + ); diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx index dd26823..f19faaa 100644 --- a/src/client/webapp/elements/overlays/overlay-image.tsx +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -23,13 +23,15 @@ export interface ImageOverlayProps { const ImageOverlay: FC = (props: ImageOverlayProps) => { const { guild, resourceId, resourceName } = props; + // TODO: Handle errors + const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId); - const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useAsyncActionSubscription( + const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useOneTimeAsyncAction( async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null), './img/loading.svg', [ guild, resource ] ) - const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useAsyncActionSubscription( + const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useOneTimeAsyncAction( async () => { if (!resource) return null; const fileTypeInfo = (await FileType.fromBuffer(resource.data)) ?? null; diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 6ffb3b6..7d4b570 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { createRef, FC, useEffect, useMemo, useState } from 'react'; +import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useState } from 'react'; import { ConnectionInfo } from '../../data-types'; import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; @@ -12,7 +12,7 @@ import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import ElementsUtil from '../require/elements-util'; import GuildSubscriptions from '../require/guild-subscriptions'; -import ReactHelper, { ExpectedError } from '../require/react-helper'; +import ReactHelper from '../require/react-helper'; import Button from '../components/button'; export interface PersonalizeOverlayProps { @@ -27,8 +27,6 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl throw new Error('bad avatar'); } - const isMounted = ReactHelper.useIsMountedRef(); - const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId) const displayNameInputRef = createRef(); @@ -45,8 +43,6 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl const [ avatarInputValid, setAvatarInputValid ] = useState(false); const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); - const [ submitFailMessage, setSubmitFailMessage ] = useState(null); - useEffect(() => { if (avatarResource) { if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data); @@ -71,45 +67,46 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl return null; }, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); - const [ _, submitError, submitButtonText, submitButtonShaking, submitButtonCallback ] = ReactHelper.useAsyncButtonSubscription(async (): Promise => { - if (validationErrorMessage) throw new ExpectedError('invalid input'); - setSubmitFailMessage(null); - - if (displayName !== savedDisplayName) { - // Save display name - try { - await guild.requestSetDisplayName(displayName); - if (!isMounted.current) return; - setSavedDisplayName(displayName); - } catch (e: unknown) { - if (!isMounted.current) return; - setSubmitFailMessage('error setting guild name'); - throw e; + const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = ReactHelper.useSubmitButton( + async (isMounted: MutableRefObject) => { + if (validationErrorMessage) return { result: null, errorMessage: 'Invalid input' }; + + if (displayName !== savedDisplayName) { + // Save display name + try { + await guild.requestSetDisplayName(displayName); + if (!isMounted.current) return { result: null, errorMessage: null }; + setSavedDisplayName(displayName); + } catch (e: unknown) { + LOG.error('Error setting guild name', e); + return { result: null, errorMessage: 'Error setting guild name' }; + } } - } - if (avatarBuff && avatarBuff?.toString('hex') !== savedAvatarBuff?.toString('hex')) { - // Save avatar - try { - await guild.requestSetAvatar(avatarBuff); - if (!isMounted.current) return; - setSavedAvatarBuff(avatarBuff); - } catch (e: unknown) { - if (!isMounted.current) return; - setSubmitFailMessage('error setting avatar'); - throw e; + if (avatarBuff && avatarBuff?.toString('hex') !== savedAvatarBuff?.toString('hex')) { + // Save avatar + try { + await guild.requestSetAvatar(avatarBuff); + if (!isMounted.current) return { result: null, errorMessage: null }; + setSavedAvatarBuff(avatarBuff); + } catch (e: unknown) { + LOG.error('Error setting guild avatar', e); + return { result: null, errorMessage: 'Error setting guild avatar' }; + } } - } - ElementsUtil.closeReactOverlay(document); - }, { start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, [ validationErrorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ]); + ElementsUtil.closeReactOverlay(document); + return { result: null, errorMessage: null }; + }, + [ validationErrorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ] + ); //if (saveFailed) return 'Unable to save personalization'; const errorMessage = useMemo(() => { if (validationErrorMessage) return validationErrorMessage; if (submitFailMessage) return submitFailMessage; return null; - }, [ validationErrorMessage, submitError ]); + }, [ validationErrorMessage, submitFailMessage ]); return (
@@ -128,12 +125,12 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl maxLength={Globals.MAX_DISPLAY_NAME_LENGTH} value={displayName} setValue={setDisplayName} setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage} - onEnterKeyDown={submitButtonCallback} + onEnterKeyDown={submitFunc} />
- + ); diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 9a363a6..ad7c5ac 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { DependencyList, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactDOMServer from "react-dom/server"; import { ShouldNeverHappenError } from "../../data-types"; import Util from '../../util'; @@ -32,7 +32,7 @@ export default class ReactHelper { return isMounted; } - static useAsyncActionSubscription( + static useOneTimeAsyncAction( actionFunc: () => Promise, initialValue: V, deps: DependencyList @@ -64,17 +64,72 @@ export default class ReactHelper { return [ value, error ]; } - static useAsyncButtonSubscription( - actionFunc: () => Promise, - stateText: { start: string, pending: string, error: string, done: string }, - deps: DependencyList - ): [ result: T | null, error: unknown | null, text: string, shaking: boolean, callback: () => void ] { + static useSubmitButton( + actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null, result: ResultType | null }>, + deps: DependencyList, + stateTextMapping?: { start?: string, pending?: string, error?: string, done?: string } + ): [ submitFunc: () => void, buttonText: string, buttonShaking: boolean, errorMessage: string | null, result: ResultType | null ] { + const fullStateTextMapping = { ...{ start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, ...stateTextMapping } + const isMounted = useRef(false); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; } }); + const [ result, setResult ] = useState(null); + const [ errorMessage, setErrorMessage ] = useState(null); + + const [ pending, setPending ] = useState(false); + const [ complete, setComplete ] = useState(false); + + const [ buttonShaking, setButtonShaking ] = useState(false); + + const buttonText = useMemo(() => { + if (errorMessage) return fullStateTextMapping.error; + if (pending) return fullStateTextMapping.pending; + if (complete) return fullStateTextMapping.done; + return fullStateTextMapping.start; + }, [ stateTextMapping, errorMessage, pending, complete ]); + + async function shakeButton() { + setButtonShaking(true); + await Util.sleep(400); + if (!isMounted.current) return; + setButtonShaking(false); + } + + const submitFunc = useCallback(async () => { + if (pending) return; + setErrorMessage(null); + setPending(true); + try { + const { result, errorMessage } = await actionFunc(isMounted); + if (!isMounted.current) return; + setResult(result); + setErrorMessage(errorMessage); + setPending(false); + setComplete(true); + if (errorMessage) await shakeButton(); + } catch (e: unknown) { + LOG.error('unable to perform submit button actionFunc', e); + setResult(null); + setErrorMessage('Unknown error'); + setPending(false); + setComplete(true); + } + }, [ ...deps, pending, actionFunc ]); + + return [ submitFunc, buttonText, buttonShaking, errorMessage, result ]; + } + + static useAsyncButtonSubscription( + actionFunc: () => Promise, + stateText: { start: string, pending: string, error: string, done: string }, + deps: DependencyList + ): [ result: T | null, error: unknown | null, text: string, shaking: boolean, callback: () => void ] { + const isMounted = ReactHelper.useIsMountedRef(); + const [ result, setResult ] = useState(null); const [ error, setError ] = useState(null);