async button subscription, overlay channel consolidation

This commit is contained in:
Michael Peters 2021-12-16 19:25:55 -06:00
parent 965de83433
commit b5f22212d9
14 changed files with 149 additions and 261 deletions

View File

@ -80,6 +80,10 @@ export class Channel implements WithEquals<Channel> {
);
}
static sortByIndex(a: Channel, b: Channel) {
return a.index - b.index;
}
toString() {
return `${this.name}#${this.index}`;
}

View File

@ -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, <ModifyChannelOverlay document={document} guild={guild} channel={channel} />);
ElementsUtil.presentReactOverlay(document, <ChannelOverlay document={document} guild={guild} channel={channel} />);
});
q.$$$(element, '.modify').addEventListener('mouseenter', () => {

View File

@ -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<HTMLDivElement>;
buttonMessage: string;
submitting: boolean;
submitFailed: boolean;
shaking: boolean;
onSubmit: () => void;
errorMessage: string | null;
infoMessage?: string | null;
children: JSX.Element; // buttons list
}
const SubmitOverlayLower: FC<SubmitOverlayLowerProps> = React.forwardRef((props: SubmitOverlayLowerProps, ref: Ref<HTMLDivElement>) => {
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<SubmitOverlayLowerProps> = React.forwardRef((props:
<div className="error">{errorMessage ?? infoMessage ?? null}</div>
</div>
<div className="buttons">
<Button ref={ref} onClick={onSubmit} shaking={shaking}>{buttonText}</Button>
{children}
</div>
</div>
);

View File

@ -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, <CreateChannelOverlay document={document} guild={guild} />);
ElementsUtil.presentReactOverlay(document, <ChannelOverlay document={document} guild={guild} />);
});
}

View File

@ -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';

View File

@ -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<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(), []);
const submitButtonRef = createRef<HTMLDivElement>();
const [ submitting, setSubmitting ] = useState<boolean>(false);
const [ submitFailed, setSubmitFailed ] = useState<boolean>(false);
const [ shaking, setShaking ] = useState<boolean>(false);
const [ displayName, setDisplayName ] = useState<string>(exampleDisplayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
@ -92,49 +89,47 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (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<boolean> => {
if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) {
return false;
}
const [ _, submitError, submitButtonText, submitButtonShaking, submitButtonCallback ] = ReactHelper.useAsyncButtonSubscription(async (): Promise<void> => {
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 (
<div className="content add-guild">
@ -157,16 +152,13 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={() => { submitButtonRef.current?.click(); }}
onEnterKeyDown={submitButtonCallback}
/>
</div>
</div>
<SubmitOverlayLower
ref={submitButtonRef}
buttonMessage="Add Guild"
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
onSubmit={onSubmit} errorMessage={errorMessage}
/>
<SubmitOverlayLower errorMessage={errorMessage}>
<Button shaking={submitButtonShaking} onClick={submitButtonCallback}>{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 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<ModifyChannelOverlayProps> = (props: ModifyChannelOverlayProps) => {
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);
const [ submitting, setSubmitting ] = useState<boolean>(false);
const [ submitFailed, setSubmitFailed ] = useState<boolean>(false);
const [ shaking, setShaking ] = useState<boolean>(false);
const [ name, setName ] = useState<string>(channel.name);
const [ flavorText, setFlavorText ] = useState<string>(channel.flavorText ?? '');
const [ queryFailed, setQueryFailed ] = useState<boolean>(false);
const [ name, setName ] = useState<string>(channel?.name ?? '');
const [ flavorText, setFlavorText ] = useState<string>(channel?.flavorText ?? '');
const [ nameInputValid, setNameInputValid ] = useState<boolean>(false);
const [ nameInputMessage, setNameInputMessage ] = useState<string | null>(null);
@ -40,17 +39,26 @@ const ModifyChannelOverlay: FC<ModifyChannelOverlayProps> = (props: ModifyChanne
const [ flavorTextInputValid, setFlavorTextInputValid ] = useState<boolean>(false);
const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState<string | null>(null);
const [ submitFailMessage, setSubmitFailMessage ] = useState<string | null>(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<ModifyChannelOverlayProps> = (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<void> => {
if (validationErrorMessage) throw new ExpectedError('invalid input');
setSubmitFailMessage(null);
const doSubmit = useCallback(async (): Promise<boolean> => {
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 (
<div className="content submit-dialog modify-channel">
@ -99,7 +109,7 @@ const ModifyChannelOverlay: FC<ModifyChannelOverlayProps> = (props: ModifyChanne
<div className="channel-name">
<TextInput
ref={nameInputRef}
label="Channel Name" placeholder={channel.name}
label="Channel Name" placeholder={channel?.name ?? 'channel-name'}
maxLength={Globals.MAX_CHANNEL_NAME_LENGTH}
value={name} setValue={setName}
setValid={setNameInputValid} setMessage={setNameInputMessage}
@ -110,21 +120,19 @@ const ModifyChannelOverlay: FC<ModifyChannelOverlayProps> = (props: ModifyChanne
<div className="flavor-text">
<TextInput
ref={flavorTextInputRef}
label="Flavor Text" placeholder={channel.flavorText ?? '(optional)'}
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={() => { submitButtonRef.current?.click(); }}
onEnterKeyDown={submit}
/>
</div>
<SubmitOverlayLower
ref={submitButtonRef}
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
buttonMessage="Create Channel" onSubmit={onSubmit}
errorMessage={errorMessage} infoMessage={infoMessage}
/>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submit}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
);
}
export default ModifyChannelOverlay;
export default ChannelOverlay;

View File

@ -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<CreateChannelOverlayProps> = (props: CreateChannelOverlayProps) => {
const { document, guild } = props;
const nameInputRef = createRef<HTMLInputElement>();
const flavorTextInputRef = createRef<HTMLInputElement>();
const submitButtonRef = createRef<HTMLDivElement>();
const [ edited, setEdited ] = useState<boolean>(false);
const [ submitting, setSubmitting ] = useState<boolean>(false);
const [ submitFailed, setSubmitFailed ] = useState<boolean>(false);
const [ shaking, setShaking ] = useState<boolean>(false);
const [ name, setName ] = useState<string>('');
const [ flavorText, setFlavorText ] = useState<string>('');
const [ queryFailed, setQueryFailed ] = useState<boolean>(false);
const [ nameInputValid, setNameInputValid ] = useState<boolean>(false);
const [ nameInputMessage, setNameInputMessage ] = useState<string | null>(null);
const [ flavorTextInputValid, setFlavorTextInputValid ] = useState<boolean>(false);
const [ flavorTextInputMessage, setFlavorTextInputMessage ] = useState<string | null>(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<boolean> => {
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 (
<div className="content submit-dialog modify-channel">
<div className="preview channel-title">
<div className="channel-icon">{BaseElements.TEXT_CHANNEL_ICON}</div>
<div className="channel-name">{name}</div>
<div className="channel-flavor-divider"></div>
<div className="channel-flavor-text">{flavorText}</div>
</div>
<div className="channel-name">
<TextInput
ref={nameInputRef}
label="Channel Name" placeholder="channel-name"
maxLength={Globals.MAX_CHANNEL_NAME_LENGTH}
value={name} setValue={setName}
setValid={setNameInputValid} setMessage={setNameInputMessage}
valueMap={value => value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')}
onEnterKeyDown={() => flavorTextInputRef.current?.focus()}
/>
</div>
<div className="flavor-text">
<TextInput
ref={flavorTextInputRef}
label="Flavor Text" placeholder="(optional)"
maxLength={Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH}
allowEmpty={true}
value={flavorText} setValue={setFlavorText}
setValid={setFlavorTextInputValid} setMessage={setFlavorTextInputMessage}
onEnterKeyDown={() => { submitButtonRef.current?.click(); }}
/>
</div>
<SubmitOverlayLower
ref={submitButtonRef}
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
buttonMessage="Create Channel" onSubmit={onSubmit}
errorMessage={errorMessage} infoMessage={infoMessage}
/>
</div>
);
}
export default CreateChannelOverlay;

View File

@ -23,7 +23,6 @@ const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSetting
useEffect(() => {
if (selectedId === 'overview') setDisplay(<GuildOverviewDisplay guild={guild} />);
//if (selectedId === 'channels') setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
//if (selectedId === 'roles' ) setDisplay(<GuildOverviewDisplay guild={guild} guildMeta={guildMeta} />);
if (selectedId === 'invites' ) setDisplay(<GuildInvitesDisplay guild={guild} />);
}, [ selectedId ]);
@ -36,7 +35,6 @@ const GuildSettingsOverlay: FC<GuildSettingsOverlayProps> = (props: GuildSetting
<div className="content display-swapper guild-settings">
<ChoicesControl title={guildNameText} selectedId={selectedId} setSelectedId={setSelectedId} choices={[
{ id: 'overview', display: 'Overview' },
{ id: 'channels', display: 'Channels' },
{ id: 'roles', display: 'Roles' },
{ id: 'invites', display: 'Invites' },
]} />

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, { 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<PersonalizeOverlayProps> = (props: PersonalizeOverl
throw new Error('bad avatar');
}
const isMounted = ReactHelper.useIsMountedRef();
const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, connection.avatarResourceId)
const displayNameInputRef = createRef<HTMLInputElement>();
const [ submitting, setSubmitting ] = useState<boolean>(false);
const [ submitFailed, setSubmitFailed ] = useState<boolean>(false);
const [ shaking, setShaking ] = useState<boolean>(false);
const [ savedDisplayName, setSavedDisplayName ] = useState<string>(connection.displayName);
const [ savedAvatarBuff, setSavedAvatarBuff ] = useState<Buffer | null>(null);
const [ displayName, setDisplayName ] = useState<string>(connection.displayName);
const [ avatarBuff, setAvatarBuff ] = useState<Buffer | null>(null);
const [ saveFailed, setSaveFailed ] = useState<boolean>(false);
const [ displayNameInputValid, setDisplayNameInputValid ] = useState<boolean>(false);
const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState<string | null>(null);
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);
@ -58,13 +58,12 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (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<PersonalizeOverlayProps> = (props: PersonalizeOverl
return null;
}, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
const doSubmit = useCallback(async (): Promise<boolean> => {
if (errorMessage) return false;
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) {
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<PersonalizeOverlayProps> = (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 (
<div className="content personalize">
@ -126,15 +128,13 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={onSubmit}
onEnterKeyDown={submitButtonCallback}
/>
</div>
</div>
<SubmitOverlayLower
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
buttonMessage="Save" onSubmit={onSubmit}
errorMessage={errorMessage} infoMessage={infoMessage}
/>
<SubmitOverlayLower errorMessage={errorMessage} infoMessage={infoMessage}>
<Button shaking={submitButtonShaking} onClick={submitButtonCallback}>{submitButtonText}</Button>
</SubmitOverlayLower>
</div>
);
}

View File

@ -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";

View File

@ -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<Channel, 'new-channels', 'update-channels', 'remove-channels', 'conflict-channels'>(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<Channel>) => changes,
sortFunc: Channel.sortByIndex
}, fetchChannelsFunc);
}
static useTokensSubscription(guild: CombinedGuild) {
const fetchTokensFunc = useCallback(async () => {
//LOG.silly('fetching tokens for subscription');

View File

@ -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<boolean>(false);
useEffect(() => {
isMounted.current = true;
return () => { isMounted.current = false; }
});
return isMounted;
}
static useAsyncActionSubscription<T, V>(
actionFunc: () => Promise<T>,
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();