diff --git a/src/client/webapp/data-types.ts b/src/client/webapp/data-types.ts index 56c17f3..e81c0fc 100644 --- a/src/client/webapp/data-types.ts +++ b/src/client/webapp/data-types.ts @@ -80,6 +80,10 @@ export class Channel implements WithEquals { ); } + static sortByIndex(a: Channel, b: Channel) { + return a.index - b.index; + } + toString() { return `${this.name}#${this.index}`; } diff --git a/src/client/webapp/elements/channel.tsx b/src/client/webapp/elements/channel.tsx index 9641c6c..fee8b47 100644 --- a/src/client/webapp/elements/channel.tsx +++ b/src/client/webapp/elements/channel.tsx @@ -8,7 +8,7 @@ import UI from '../ui'; import Actions from '../actions'; import Q from '../q-module'; import CombinedGuild from '../guild-combined'; -import ModifyChannelOverlay from './overlays/overlay-modify-channel'; +import ChannelOverlay from './overlays/overlay-channel'; export default function createChannel(document: Document, q: Q, ui: UI, guild: CombinedGuild, channel: Channel) { const element = ReactHelper.createElementFromJSX( @@ -38,7 +38,7 @@ export default function createChannel(document: Document, q: Q, ui: UI, guild: C if (modifyContextElement.parentElement) { modifyContextElement.parentElement.removeChild(modifyContextElement); } - ElementsUtil.presentReactOverlay(document, ); + ElementsUtil.presentReactOverlay(document, ); }); q.$$$(element, '.modify').addEventListener('mouseenter', () => { diff --git a/src/client/webapp/elements/components/submit-overlay-lower.tsx b/src/client/webapp/elements/components/submit-overlay-lower.tsx index 2d9e395..89b5f14 100644 --- a/src/client/webapp/elements/components/submit-overlay-lower.tsx +++ b/src/client/webapp/elements/components/submit-overlay-lower.tsx @@ -1,35 +1,21 @@ import React, { FC, Ref, useMemo } from 'react'; -import Button from './button'; // Includes a submit button and error message export interface SubmitOverlayLowerProps { ref?: Ref; - buttonMessage: string; - submitting: boolean; - submitFailed: boolean; - shaking: boolean; - onSubmit: () => void; errorMessage: string | null; infoMessage?: string | null; + + children: JSX.Element; // buttons list } const SubmitOverlayLower: FC = React.forwardRef((props: SubmitOverlayLowerProps, ref: Ref) => { - const { buttonMessage, submitting, submitFailed, shaking, onSubmit, errorMessage, infoMessage } = props; + const { errorMessage, infoMessage, children } = props; // TODO: ref should be an imperative handle - const buttonText = useMemo(() => { - if (submitting) { - return 'Submitting...'; - } else if (submitFailed) { - return 'Try Again'; - } else { - return buttonMessage; - } - }, [ submitting, submitFailed ]); - const message = useMemo(() => errorMessage ?? infoMessage ?? null, [ errorMessage, infoMessage ]); const isEmpty = useMemo(() => message === null, [ message ]); @@ -39,7 +25,7 @@ const SubmitOverlayLower: FC = React.forwardRef((props:
{errorMessage ?? infoMessage ?? null}
- + {children}
); diff --git a/src/client/webapp/elements/context-menu-guild-title.tsx b/src/client/webapp/elements/context-menu-guild-title.tsx index 74112a9..c58b51d 100644 --- a/src/client/webapp/elements/context-menu-guild-title.tsx +++ b/src/client/webapp/elements/context-menu-guild-title.tsx @@ -15,7 +15,7 @@ import CombinedGuild from '../guild-combined'; import React from 'react'; import ReactHelper from './require/react-helper'; import GuildSettingsOverlay from './overlays/overlay-guild-settings'; -import CreateChannelOverlay from './overlays/overlay-create-channel'; +import ChannelOverlay from './overlays/overlay-channel'; export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): Element { if (ui.activeConnection === null) { @@ -81,7 +81,7 @@ export default function createGuildTitleContextMenu(document: Document, q: Q, ui if (ui.activeConnection.privileges.includes('modify_channels')) { q.$$$(element, '.item.create-channel').addEventListener('click', () => { element.removeSelf(); - ElementsUtil.presentReactOverlay(document, ); + ElementsUtil.presentReactOverlay(document, ); }); } diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 9e955ca..f124a1f 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -3,8 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { FC } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import CombinedGuild from '../../guild-combined'; import Display from '../components/display'; import InvitePreview from '../components/invite-preview'; diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index 58acdd1..79864f7 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -15,8 +15,9 @@ import UI from '../../ui'; import CombinedGuild from '../../guild-combined'; import ElementsUtil from '../require/elements-util'; import InvitePreview from '../components/invite-preview'; -import ReactHelper from '../require/react-helper'; +import ReactHelper, { ExpectedError } from '../require/react-helper'; import * as fs from 'fs/promises'; +import Button from '../components/button'; export interface IAddGuildData { name: string, @@ -59,16 +60,12 @@ 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(), []); - const submitButtonRef = createRef(); - - const [ submitting, setSubmitting ] = useState(false); - const [ submitFailed, setSubmitFailed ] = useState(false); - const [ shaking, setShaking ] = useState(false); - const [ displayName, setDisplayName ] = useState(exampleDisplayName); const [ avatarBuff, setAvatarBuff ] = useState(null); @@ -92,49 +89,47 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) } }, [ exampleAvatarBuff ]); - const errorMessage = useMemo(() => { + const validationErrorMessage = useMemo(() => { if (exampleAvatarBuffError && !avatarBuff) return 'Unable to load example avatar'; if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; - if (addGuildFailedMessage !== null) return addGuildFailedMessage; return null; }, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]); - const doSubmit = useCallback(async (): Promise => { - if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) { - return false; - } + const [ _, submitError, submitButtonText, submitButtonShaking, submitButtonCallback ] = ReactHelper.useAsyncButtonSubscription(async (): Promise => { + if (validationErrorMessage || !avatarBuff) throw new ExpectedError('invalid input'); + if (expired) { setAddGuildFailedMessage('token expired'); - return false; + 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'); } - return false; + 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 ]); - return true; - - }, [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ]); - - const onSubmit = useCallback( - ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), - [ doSubmit, shaking, submitFailed, submitFailed, submitting ] - ); + const errorMessage = useMemo(() => { + if (validationErrorMessage) return validationErrorMessage; + if (addGuildFailedMessage) return addGuildFailedMessage; + return null; + }, [ validationErrorMessage, addGuildFailedMessage ]) return (
@@ -157,16 +152,13 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) maxLength={Globals.MAX_DISPLAY_NAME_LENGTH} value={displayName} setValue={setDisplayName} setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage} - onEnterKeyDown={() => { submitButtonRef.current?.click(); }} + onEnterKeyDown={submitButtonCallback} />
- + + + ); } diff --git a/src/client/webapp/elements/overlays/overlay-modify-channel.scss b/src/client/webapp/elements/overlays/overlay-channel.scss similarity index 100% rename from src/client/webapp/elements/overlays/overlay-modify-channel.scss rename to src/client/webapp/elements/overlays/overlay-channel.scss diff --git a/src/client/webapp/elements/overlays/overlay-modify-channel.tsx b/src/client/webapp/elements/overlays/overlay-channel.tsx similarity index 58% rename from src/client/webapp/elements/overlays/overlay-modify-channel.tsx rename to src/client/webapp/elements/overlays/overlay-channel.tsx index 8c3dd54..63f554e 100644 --- a/src/client/webapp/elements/overlays/overlay-modify-channel.tsx +++ b/src/client/webapp/elements/overlays/overlay-channel.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, { createRef, FC, useEffect, useMemo, useState } from 'react'; import CombinedGuild from '../../guild-combined'; import BaseElements from '../require/base-elements'; import TextInput from '../components/input-text'; @@ -11,28 +11,27 @@ 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 Button from '../components/button'; -export interface ModifyChannelOverlayProps { +export interface ChannelOverlayProps { document: Document; guild: CombinedGuild; - channel: Channel; + channel?: Channel; } -const ModifyChannelOverlay: FC = (props: ModifyChannelOverlayProps) => { +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); - const [ submitting, setSubmitting ] = useState(false); - const [ submitFailed, setSubmitFailed ] = useState(false); - const [ shaking, setShaking ] = useState(false); - const [ name, setName ] = useState(channel.name); - const [ flavorText, setFlavorText ] = useState(channel.flavorText ?? ''); - - const [ queryFailed, setQueryFailed ] = useState(false); + const [ name, setName ] = useState(channel?.name ?? ''); + const [ flavorText, setFlavorText ] = useState(channel?.flavorText ?? ''); const [ nameInputValid, setNameInputValid ] = useState(false); const [ nameInputMessage, setNameInputMessage ] = useState(null); @@ -40,17 +39,26 @@ const ModifyChannelOverlay: FC = (props: ModifyChanne const [ flavorTextInputValid, setFlavorTextInputValid ] = useState(false); const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState(null); + const [ submitFailMessage, setSubmitFailMessage ] = useState(null); + useEffect(() => { nameInputRef.current?.focus(); }, []); - const errorMessage = useMemo(() => { + useEffect(() => { + if (channel) { + setEdited(name !== channel.name || ((flavorText === '' ? null : flavorText) !== channel.flavorText)); + } else { + setEdited(name.length > 0 && flavorText.length > 0); + } + }, [ name, flavorText ]); + + const validationErrorMessage = useMemo(() => { if (!edited) return null; if ( !nameInputValid && nameInputMessage) return nameInputMessage; if (!flavorTextInputValid && flavorTextInputMessage) return flavorTextInputMessage; - if (queryFailed) return 'Unable to create new channel'; return null; - }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage, queryFailed ]); + }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); const infoMessage = useMemo(() => { if ( nameInputValid && nameInputMessage) return nameInputMessage; @@ -58,35 +66,37 @@ const ModifyChannelOverlay: FC = (props: ModifyChanne return null; }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); - useEffect(() => { - if (name.length > 0 || flavorText.length > 0) { - setEdited(true); - } - }, [ name, flavorText ]); + const [ _, submitError, submitButtonText, submitButtonShaking, submit ] = ReactHelper.useAsyncButtonSubscription(async (): Promise => { + if (validationErrorMessage) throw new ExpectedError('invalid input'); + setSubmitFailMessage(null); - const doSubmit = useCallback(async (): Promise => { - LOG.debug('submitting'); - if (!edited) return false; - if (errorMessage) return false; + if (!edited) { + ElementsUtil.closeReactOverlay(document); + return; + } try { // Make sure to null out flavor text if empty - await guild.requestDoUpdateChannel(channel.id, name, flavorText === '' ? null : flavorText); + if (channel) { + await guild.requestDoUpdateChannel(channel.id, name, flavorText === '' ? null : flavorText); + } else { + await guild.requestDoCreateChannel(name, flavorText === '' ? null : flavorText); + } + if (!isMounted.current) return; } catch (e: unknown) { - LOG.error('error creating channel', e); - setQueryFailed(true); - return false; + 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 ]); - return true; - }, [ errorMessage, name, flavorText ]); - - const onSubmit = useCallback( - ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), - [ doSubmit, shaking, submitFailed, submitFailed, submitting ] - ); + const errorMessage = useMemo(() => { + if (validationErrorMessage) return validationErrorMessage; + if (submitError) return 'Unable to modify channel'; + return null; + }, [ validationErrorMessage, submitError ]); return (
@@ -99,7 +109,7 @@ const ModifyChannelOverlay: FC = (props: ModifyChanne
= (props: ModifyChanne
{ submitButtonRef.current?.click(); }} + onEnterKeyDown={submit} />
- + + +
); } -export default ModifyChannelOverlay; + +export default ChannelOverlay; diff --git a/src/client/webapp/elements/overlays/overlay-create-channel.tsx b/src/client/webapp/elements/overlays/overlay-create-channel.tsx deleted file mode 100644 index 428104e..0000000 --- a/src/client/webapp/elements/overlays/overlay-create-channel.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as electronRemote from '@electron/remote'; -const electronConsole = electronRemote.getGlobal('console') as Console; -import Logger from '../../../../logger/logger'; -const LOG = Logger.create(__filename, electronConsole); - -import React, { createRef, FC, useCallback, useEffect, useMemo, useState } from 'react'; -import CombinedGuild from '../../guild-combined'; -import BaseElements from '../require/base-elements'; -import TextInput from '../components/input-text'; -import SubmitOverlayLower from '../components/submit-overlay-lower'; -import Globals from '../../globals'; -import ElementsUtil from '../require/elements-util'; - -export interface CreateChannelOverlayProps { - document: Document; - guild: CombinedGuild; -} -const CreateChannelOverlay: FC = (props: CreateChannelOverlayProps) => { - const { document, guild } = props; - - const nameInputRef = createRef(); - const flavorTextInputRef = createRef(); - const submitButtonRef = createRef(); - - const [ edited, setEdited ] = useState(false); - const [ submitting, setSubmitting ] = useState(false); - const [ submitFailed, setSubmitFailed ] = useState(false); - const [ shaking, setShaking ] = useState(false); - - const [ name, setName ] = useState(''); - const [ flavorText, setFlavorText ] = useState(''); - - const [ queryFailed, setQueryFailed ] = useState(false); - - const [ nameInputValid, setNameInputValid ] = useState(false); - const [ nameInputMessage, setNameInputMessage ] = useState(null); - - const [ flavorTextInputValid, setFlavorTextInputValid ] = useState(false); - const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState(null); - - useEffect(() => { - nameInputRef.current?.focus(); - }, []); - - const errorMessage = useMemo(() => { - if (!edited) return null; - if ( !nameInputValid && nameInputMessage) return nameInputMessage; - if (!flavorTextInputValid && flavorTextInputMessage) return flavorTextInputMessage; - if (queryFailed) return 'Unable to create new channel'; - return null; - }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage, queryFailed ]); - - const infoMessage = useMemo(() => { - if ( nameInputValid && nameInputMessage) return nameInputMessage; - if (flavorTextInputValid && flavorTextInputMessage) return flavorTextInputMessage; - return null; - }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); - - useEffect(() => { - if (name.length > 0 || flavorText.length > 0) { - setEdited(true); - } - }, [ name, flavorText ]); - - const doSubmit = useCallback(async (): Promise => { - LOG.debug('submitting'); - if (!edited) return false; - if (errorMessage) return false; - - try { - // Make sure to null out flavor text if empty - await guild.requestDoCreateChannel(name, flavorText === '' ? null : flavorText); - } catch (e: unknown) { - LOG.error('error creating channel', e); - setQueryFailed(true); - return false; - } - - ElementsUtil.closeReactOverlay(document); - - return true; - }, [ errorMessage, name, flavorText ]); - - const onSubmit = useCallback( - ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), - [ doSubmit, shaking, submitFailed, submitFailed, submitting ] - ); - - return ( -
-
-
{BaseElements.TEXT_CHANNEL_ICON}
-
{name}
-
-
{flavorText}
-
-
- value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')} - onEnterKeyDown={() => flavorTextInputRef.current?.focus()} - /> -
-
- { submitButtonRef.current?.click(); }} - /> -
- -
- ); -} -export default CreateChannelOverlay; \ No newline at end of file diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 14b7776..30dc1c8 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -23,7 +23,6 @@ const GuildSettingsOverlay: FC = (props: GuildSetting useEffect(() => { if (selectedId === 'overview') setDisplay(); - //if (selectedId === 'channels') setDisplay(); //if (selectedId === 'roles' ) setDisplay(); if (selectedId === 'invites' ) setDisplay(); }, [ selectedId ]); @@ -36,7 +35,6 @@ const GuildSettingsOverlay: FC = (props: GuildSetting
diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index f8d6e8c..6ffb3b6 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, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { createRef, FC, useEffect, useMemo, useState } from 'react'; import { ConnectionInfo } from '../../data-types'; import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; @@ -12,6 +12,8 @@ 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 Button from '../components/button'; export interface PersonalizeOverlayProps { document: Document; @@ -25,28 +27,26 @@ 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(); - const [ submitting, setSubmitting ] = useState(false); - const [ submitFailed, setSubmitFailed ] = useState(false); - const [ shaking, setShaking ] = useState(false); - const [ savedDisplayName, setSavedDisplayName ] = useState(connection.displayName); const [ savedAvatarBuff, setSavedAvatarBuff ] = useState(null); const [ displayName, setDisplayName ] = useState(connection.displayName); const [ avatarBuff, setAvatarBuff ] = useState(null); - const [ saveFailed, setSaveFailed ] = useState(false); - const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState(null); const [ avatarInputValid, setAvatarInputValid ] = useState(false); const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); + const [ submitFailMessage, setSubmitFailMessage ] = useState(null); + useEffect(() => { if (avatarResource) { if (avatarBuff === savedAvatarBuff) setAvatarBuff(avatarResource.data); @@ -58,13 +58,12 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl displayNameInputRef.current?.focus(); }, []); - const errorMessage = useMemo(() => { + const validationErrorMessage = useMemo(() => { if (avatarResourceError) return 'Unable to load avatar'; if ( !avatarInputValid && avatarInputMessage) return avatarInputMessage; if (!displayNameInputValid && displayNameInputMessage) return displayNameInputMessage; - if (saveFailed) return 'Unable to save personalization'; return null; - }, [ saveFailed, avatarResourceError, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); + }, [ avatarResourceError, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); const infoMessage = useMemo(() => { if (avatarInputValid && avatarInputMessage) return avatarInputMessage; @@ -72,18 +71,20 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl return null; }, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); - const doSubmit = useCallback(async (): Promise => { - if (errorMessage) return false; + 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) { - LOG.error('error setting guild name', e); - setSaveFailed(true); - return false; + if (!isMounted.current) return; + setSubmitFailMessage('error setting guild name'); + throw e; } } @@ -91,23 +92,24 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl // Save avatar try { await guild.requestSetAvatar(avatarBuff); + if (!isMounted.current) return; setSavedAvatarBuff(avatarBuff); } catch (e: unknown) { - LOG.error('error setting avatar', e); - setSaveFailed(true); - return false; + if (!isMounted.current) return; + setSubmitFailMessage('error setting avatar'); + throw e; } } ElementsUtil.closeReactOverlay(document); + }, { start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, [ validationErrorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ]); - return true; - }, [ errorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ]); - - const onSubmit = useCallback( - ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }), - [ doSubmit, shaking, submitFailed, submitFailed, submitting ] - ); + //if (saveFailed) return 'Unable to save personalization'; + const errorMessage = useMemo(() => { + if (validationErrorMessage) return validationErrorMessage; + if (submitFailMessage) return submitFailMessage; + return null; + }, [ validationErrorMessage, submitError ]); return (
@@ -126,15 +128,13 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl maxLength={Globals.MAX_DISPLAY_NAME_LENGTH} value={displayName} setValue={setDisplayName} setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage} - onEnterKeyDown={onSubmit} + onEnterKeyDown={submitButtonCallback} />
- + + +
); } diff --git a/src/client/webapp/elements/overlays/overlays.scss b/src/client/webapp/elements/overlays/overlays.scss index c95b1e4..ae0f12d 100644 --- a/src/client/webapp/elements/overlays/overlays.scss +++ b/src/client/webapp/elements/overlays/overlays.scss @@ -2,5 +2,5 @@ @import "./overlay-error-message.scss"; @import "./overlay-guild-settings.scss"; @import "./overlay-image.scss"; -@import "./overlay-modify-channel.scss"; +@import "./overlay-channel.scss"; @import "./overlay-personalize.scss"; diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index d31af98..ea3537d 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -11,7 +11,7 @@ import { AutoVerifierChangesType } from "../../auto-verifier"; import { Conflictable, Connectable } from "../../guild-types"; import { EventEmitter } from 'tsee'; import { IDQuery } from '../../auto-verifier-with-args'; -import { Token } from '../../data-types'; +import { Token, Channel } from '../../data-types'; export type SingleSubscriptionEvents = { 'fetch': () => void; @@ -323,6 +323,23 @@ export default class GuildSubscriptions { }, fetchResourceFunc); } + static useChannelsSubscription(guild: CombinedGuild) { + const fetchChannelsFunc = useCallback(async () => { + return await guild.fetchChannels(); + }, [ guild ]); + return GuildSubscriptions.useMultipleGuildSubscription(guild, { + newEventName: 'new-channels', + newEventArgsMap: (channels: Channel[]) => channels, + updatedEventName: 'update-channels', + updatedEventArgsMap: (updatedChannels: Channel[]) => updatedChannels, + removedEventName: 'remove-channels', + removedEventArgsMap: (removedChannels: Channel[]) => removedChannels, + conflictEventName: 'conflict-channels', + conflictEventArgsMap: (changesType: AutoVerifierChangesType, changes: Changes) => changes, + sortFunc: Channel.sortByIndex + }, fetchChannelsFunc); + } + static useTokensSubscription(guild: CombinedGuild) { const fetchTokensFunc = useCallback(async () => { //LOG.silly('fetching tokens for subscription'); diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 9aab990..9a363a6 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -8,6 +8,8 @@ import ReactDOMServer from "react-dom/server"; import { ShouldNeverHappenError } from "../../data-types"; import Util from '../../util'; +export class ExpectedError extends Error {} + // Helper function so we can use JSX before fully committing to React export default class ReactHelper { @@ -21,6 +23,15 @@ export default class ReactHelper { return document.body.firstElementChild; } + static useIsMountedRef() { + const isMounted = useRef(false); + useEffect(() => { + isMounted.current = true; + return () => { isMounted.current = false; } + }); + return isMounted; + } + static useAsyncActionSubscription( actionFunc: () => Promise, initialValue: V, @@ -80,6 +91,7 @@ export default class ReactHelper { const callback = useCallback(async () => { if (pending) return; + setError(null); setPending(true); try { const value = await actionFunc();