reactify create-channel overlay

also add refs
This commit is contained in:
Michael Peters 2021-12-10 01:23:11 -06:00
parent 8e4a195464
commit 78093316ea
9 changed files with 221 additions and 85 deletions

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback, useMemo } from 'react';
import React, { FC, Ref, useCallback, useMemo } from 'react';
export enum ButtonType {
BRAND = '',
@ -8,6 +8,8 @@ export enum ButtonType {
}
interface ButtonProps {
ref?: Ref<HTMLDivElement>;
type?: ButtonType;
onClick?: () => void;
shaking?: boolean;
@ -18,7 +20,7 @@ const DefaultButtonProps: ButtonProps = {
type: ButtonType.BRAND
}
const Button: FC<ButtonProps> = (props: ButtonProps) => {
const Button: FC<ButtonProps> = React.forwardRef((props: ButtonProps, ref: Ref<HTMLDivElement>) => {
const { type, onClick, shaking, children } = { ...DefaultButtonProps, ...props };
const className = useMemo(
@ -35,7 +37,7 @@ const Button: FC<ButtonProps> = (props: ButtonProps) => {
if (onClick) onClick();
}, [ shaking, onClick ]);
return <div className={className} onClick={clickHandler}>{children}</div>
}
return <div ref={ref} className={className} onClick={clickHandler}>{children}</div>
});
export default Button;

View File

@ -3,9 +3,11 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, useEffect, useMemo, useState } from 'react';
import React, { FC, Ref, useEffect, useMemo } from 'react';
export interface TextInputProps {
ref?: Ref<HTMLInputElement>;
label: string;
placeholder?: string;
@ -21,10 +23,11 @@ export interface TextInputProps {
setMessage: React.Dispatch<React.SetStateAction<string | null>>;
onEnterKeyDown?: () => void;
valueMap?: (value: string) => string; // useful to get rid of certain characters, convert to lower case, etc.
}
const TextInput: FC<TextInputProps> = (props: TextInputProps) => {
const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown } = props;
const TextInput: FC<TextInputProps> = React.forwardRef((props: TextInputProps, ref: Ref<HTMLInputElement>) => {
const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown, valueMap } = props;
useEffect(() => {
if (maxLength !== undefined && value.length > maxLength) {
@ -46,11 +49,17 @@ const TextInput: FC<TextInputProps> = (props: TextInputProps) => {
}, [ label ]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
let value = e.target.value;
if (valueMap) value = valueMap(value);
setValue(value);
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// const controlKeys = [ 'Backspace', 'Escape', 'Tab' ];
// if (!controlKeys.includes(e.key) && validKeys && !validKeys.test(e.key)) {
// e.preventDefault();
// e.stopPropagation();
// } else
if (e.key === 'Enter') {
if (onEnterKeyDown) onEnterKeyDown();
}
@ -59,9 +68,9 @@ const TextInput: FC<TextInputProps> = (props: TextInputProps) => {
return (
<div className="text-input-react">{/* TODO: remove -react */}
{labelElement}
<input type="text" placeholder={placeholder} onChange={handleChange} onKeyDown={handleKeyDown} value={value} />
<input ref={ref} type="text" placeholder={placeholder} onChange={handleChange} onKeyDown={handleKeyDown} value={value} />
</div>
);
}
});
export default TextInput;

View File

@ -1,22 +1,22 @@
import React, { FC, useMemo, useState } from 'react';
import ElementsUtil from '../require/elements-util';
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;
doSubmit: () => Promise<boolean>;
submitting: boolean;
submitFailed: boolean;
shaking: boolean;
onSubmit: () => void;
errorMessage: string | null;
infoMessage?: string | null;
}
const SubmitOverlayLower: FC<SubmitOverlayLowerProps> = (props: SubmitOverlayLowerProps) => {
const { buttonMessage, doSubmit, errorMessage, infoMessage } = props;
const [ shaking, setShaking ] = useState<boolean>(false)
const [ submitting, setSubmitting ] = useState<boolean>(false);
const [ submitFailed, setSubmitFailed ] = useState<boolean>(false);
const SubmitOverlayLower: FC<SubmitOverlayLowerProps> = React.forwardRef((props: SubmitOverlayLowerProps, ref: Ref<HTMLDivElement>) => {
const { buttonMessage, submitting, submitFailed, shaking, onSubmit, errorMessage, infoMessage } = props;
const buttonText = useMemo(() => {
if (submitting) {
@ -28,17 +28,6 @@ const SubmitOverlayLower: FC<SubmitOverlayLowerProps> = (props: SubmitOverlayLow
}
}, [ submitting, submitFailed ]);
const onSubmit = async () => {
setSubmitting(true);
const succeeded = await doSubmit();
setSubmitting(false);
setSubmitFailed(!succeeded);
if (!succeeded) {
await ElementsUtil.delayToggleState(setShaking, 400);
}
}
const message = useMemo(() => errorMessage ?? infoMessage ?? null, [ errorMessage, infoMessage ]);
const isEmpty = useMemo(() => message === null, [ message ]);
@ -48,10 +37,10 @@ const SubmitOverlayLower: FC<SubmitOverlayLowerProps> = (props: SubmitOverlayLow
<div className="error">{errorMessage ?? infoMessage ?? null}</div>
</div>
<div className="buttons">
<Button onClick={onSubmit} shaking={shaking}>{buttonText}</Button>
<Button ref={ref} onClick={onSubmit} shaking={shaking}>{buttonText}</Button>
</div>
</div>
);
}
});
export default SubmitOverlayLower;

View File

@ -1,9 +1,7 @@
import React from 'react';
import ReactHelper from './require/react-helper.js';
import ElementsUtil from './require/elements-util.js';
import BaseElements from './require/base-elements.js';
import createPersonalizeOverlay from './overlay-personalize.js';
import Q from '../q-module.js';
import UI from '../ui.js';
import CombinedGuild from '../guild-combined.js';

View File

@ -18,6 +18,7 @@ import React from 'react';
import ReactHelper from './require/react-helper';
import GuildSettingsOverlay from './overlays/overlay-guild-settings';
import ErrorMessageOverlay from './overlays/overlay-error-message';
import CreateChannelOverlay from './overlays/overlay-create-channel';
export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): Element {
if (ui.activeConnection === null) {
@ -93,10 +94,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();
const overlay = createCreateChannelOverlay(document, q, guild);
document.body.appendChild(overlay);
q.$$$(overlay, '.text-input.channel-name').focus();
ElementsUtil.setCursorToEnd(q.$$$(overlay, '.text-input.channel-name'));
ElementsUtil.presentReactOverlay(document, <CreateChannelOverlay document={document} guild={guild} />);
});
}

View File

@ -0,0 +1,128 @@
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, useRef, 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="Flavor text (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

@ -4,7 +4,7 @@ import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import moment from 'moment';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import React, { createRef, FC, useCallback, useEffect, useMemo, useState } from 'react';
import { ConnectionInfo } from '../../data-types';
import Globals from '../../globals';
import CombinedGuild from '../../guild-combined';
@ -27,6 +27,12 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
const avatarResourceId = 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);
@ -55,28 +61,22 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
})();
}, []);
useEffect(() => {
displayNameInputRef.current?.focus();
}, []);
const errorMessage = useMemo(() => {
if (loadAvatarFailed) {
return 'Unable to load avatar';
} else if (!avatarInputValid && avatarInputMessage) {
return avatarInputMessage;
} else if (!displayNameInputValid && displayNameInputMessage) {
return displayNameInputMessage;
} else if (saveFailed) {
return 'Unable to save personalization';
} else {
return null;
}
if (loadAvatarFailed) 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, loadAvatarFailed, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
const infoMessage = useMemo(() => {
if (avatarInputValid && avatarInputMessage) {
return avatarInputMessage;
} else if (displayNameInputValid && displayNameInputMessage) {
return displayNameInputMessage;
} else {
return null;
}
if (avatarInputValid && avatarInputMessage) return avatarInputMessage;
if (displayNameInputValid && displayNameInputMessage) return displayNameInputMessage;
return null;
}, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]);
const doSubmit = useCallback(async (): Promise<boolean> => {
@ -111,6 +111,11 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
return true;
}, [ errorMessage, displayName, savedDisplayName, avatarBuff, savedAvatarBuff ]);
const onSubmit = useCallback(
ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }),
[ doSubmit, shaking, submitFailed, submitFailed, submitting ]
);
return (
<div className="content personalize">
<div className="personalization">
@ -123,14 +128,20 @@ const PersonalizeOverlay: FC<PersonalizeOverlayProps> = (props: PersonalizeOverl
</div>
<div className="display-name">
<TextInput
ref={displayNameInputRef}
label="Display Name" placeholder={savedDisplayName}
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={onSubmit}
/>
</div>
</div>
<SubmitOverlayLower buttonMessage="Save" doSubmit={doSubmit} errorMessage={errorMessage} infoMessage={infoMessage} />
<SubmitOverlayLower
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
buttonMessage="Save" onSubmit={onSubmit}
errorMessage={errorMessage} infoMessage={infoMessage}
/>
</div>
);
}

View File

@ -51,6 +51,13 @@ interface CreateDownloadListenerProps {
successFunc: ((path: string) => Promise<void> | void);
}
interface ShakingOnSubmitProps {
doSubmit: () => Promise<boolean>,
setSubmitting: React.Dispatch<React.SetStateAction<boolean>>,
setSubmitFailed: React.Dispatch<React.SetStateAction<boolean>>,
setShaking: React.Dispatch<React.SetStateAction<boolean>>
}
async function sleep(ms: number): Promise<unknown> {
return await new Promise((resolve, reject) => {
setTimeout(resolve, ms);
@ -98,6 +105,21 @@ export default class ElementsUtil {
setState(old => !start);
}
static createShakingOnSubmit(props: ShakingOnSubmitProps): () => Promise<void> {
const { doSubmit, setSubmitting, setSubmitFailed, setShaking } = props;
return async () => {
setSubmitting(true);
const succeeded = await doSubmit();
setSubmitting(false);
setSubmitFailed(!succeeded);
if (!succeeded) {
await ElementsUtil.delayToggleState(setShaking, 400);
}
}
}
static async getImageBufferSrc(buffer: Buffer): Promise<string> {
const result = await FileType.fromBuffer(buffer);
switch (result && result.mime) {

View File

@ -195,7 +195,6 @@ body > .overlay,
}
.buttons {
margin-left: 16px;
display: flex;
}
}
@ -221,7 +220,7 @@ body > .overlay,
> .content.add-guild {
min-width: 350px;
background-color: $background-secondary;
border-radius: 12px;
border-radius: 8px;
.divider {
margin: 16px;
@ -266,27 +265,6 @@ body > .overlay,
margin-left: 16px;
}
}
.lower {
padding: 16px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
background-color: $background-tertiary;
.error {
background-color: $background-secondary-alt;
color: $text-normal;
}
.error::first-letter {
text-transform: uppercase;
}
.buttons {
margin-left: 16px;
display: flex;
}
}
}
/* Modify Channel Overlay */
@ -296,13 +274,14 @@ body > .overlay,
max-width: calc(100vw - 80px);
.preview.channel-title {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
padding-right: 8px;
}
.text-input.channel-name {
text-transform: lowercase;
> .channel-name,
> .flavor-text {
margin: 16px;
}
}