AsyncButtonSubscription Improvements

This commit is contained in:
Michael Peters 2021-12-16 20:12:52 -06:00
parent b5f22212d9
commit 3c14ecb6ec
6 changed files with 160 additions and 122 deletions

View File

@ -29,7 +29,7 @@ const GuildInvitesDisplay: FC<GuildInvitesDisplayProps> = (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 ]

View File

@ -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<AddGuildOverlayProps> = (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<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
const [ displayName, setDisplayName ] = useState<string>(exampleDisplayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState<string | null>(null);
const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState<string | null>(null);
const [ displayNameInputValid, setDisplayNameInputValid ] = useState<boolean>(false);
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(null);
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(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<AddGuildOverlayProps> = (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<void> => {
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 (
<div className="content add-guild">
<InvitePreview
name={addGuildData.name} iconSrc={addGuildData.iconSrc}
url={addGuildData.url} expiresFromNow={moment.duration(addGuildData.expires - Date.now(), 'milliseconds')}
url={addGuildData.url} expiresFromNow={moment.duration(addGuildData.expires - Date.now(), 'ms')}
/>
<div className="divider"></div>
<div className="personalization">
@ -152,12 +141,12 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitButtonCallback}
onEnterKeyDown={submitFunc}
/>
</div>
</div>
<SubmitOverlayLower errorMessage={errorMessage}>
<Button shaking={submitButtonShaking} onClick={submitButtonCallback}>{submitButtonText}</Button>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
);

View File

@ -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<ChannelOverlayProps> = (props: ChannelOverlayProps) => {
const { document, guild, channel } = props;
const isMounted = ReactHelper.useIsMountedRef();
const nameInputRef = createRef<HTMLInputElement>();
const flavorTextInputRef = createRef<HTMLInputElement>();
const submitButtonRef = createRef<HTMLDivElement>();
const [ edited, setEdited ] = useState<boolean>(false);
@ -39,8 +35,6 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) =>
const [ flavorTextInputValid, setFlavorTextInputValid ] = useState<boolean>(false);
const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState<string | null>(null);
const [ submitFailMessage, setSubmitFailMessage ] = useState<string | null>(null);
useEffect(() => {
nameInputRef.current?.focus();
}, []);
@ -66,37 +60,39 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (props: ChannelOverlayProps) =>
return null;
}, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]);
const [ _, submitError, submitButtonText, submitButtonShaking, submit ] = ReactHelper.useAsyncButtonSubscription(async (): Promise<void> => {
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 (
<div className="content submit-dialog modify-channel">
@ -114,22 +110,21 @@ const ChannelOverlay: FC<ChannelOverlayProps> = (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}
/>
</div>
<div className="flavor-text">
<TextInput
ref={flavorTextInputRef}
label="Flavor Text" placeholder={channel?.flavorText ?? '(optional)'}
maxLength={Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH}
allowEmpty={true}
value={flavorText} setValue={setFlavorText}
setValid={setFlavorTextInputValid} setMessage={setFlavorTextInputMessage}
onEnterKeyDown={submit}
onEnterKeyDown={submitFunc}
/>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submit}>{submitButtonText}</Button>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
);

View File

@ -23,13 +23,15 @@ export interface ImageOverlayProps {
const ImageOverlay: FC<ImageOverlayProps> = (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;

View File

@ -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<PersonalizeOverlayProps> = (props: PersonalizeOverl
throw new Error('bad avatar');
}
const isMounted = ReactHelper.useIsMountedRef();
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId)
const displayNameInputRef = createRef<HTMLInputElement>();
@ -45,8 +43,6 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
const [ avatarInputValid, setAvatarInputValid ] = useState<boolean>(false);
const [ avatarInputMessage, setAvatarInputMessage ] = useState<string | null>(null);
const [ submitFailMessage, setSubmitFailMessage ] = useState<string | null>(null);
useEffect(() => {
if (avatarResource) {
if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data);
@ -71,45 +67,46 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
return null;
}, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
const [ _, submitError, submitButtonText, submitButtonShaking, submitButtonCallback ] = ReactHelper.useAsyncButtonSubscription(async (): Promise<void> => {
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<boolean>) => {
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 (
<div className="content personalize">
@ -128,12 +125,12 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={submitButtonCallback}
onEnterKeyDown={submitFunc}
/>
</div>
</div>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitButtonCallback}>{submitButtonText}</Button>
<Button shaking={submitButtonShaking} onClick={submitFunc}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
);

View File

@ -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<T, V>(
static useOneTimeAsyncAction<T, V>(
actionFunc: () => Promise<T>,
initialValue: V,
deps: DependencyList
@ -64,17 +64,72 @@ export default class ReactHelper {
return [ value, error ];
}
static useAsyncButtonSubscription<T>(
actionFunc: () => Promise<T>,
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<ResultType>(
actionFunc: (isMounted: MutableRefObject<boolean>) => 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<ResultType | null>(null);
const [ errorMessage, setErrorMessage ] = useState<string | null>(null);
const [ pending, setPending ] = useState<boolean>(false);
const [ complete, setComplete ] = useState<boolean>(false);
const [ buttonShaking, setButtonShaking ] = useState<boolean>(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<T>(
actionFunc: () => Promise<T>,
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<T | null>(null);
const [ error, setError ] = useState<unknown | null>(null);