Reactify modify channel

This commit is contained in:
Michael Peters 2021-12-10 01:45:17 -06:00
parent 78093316ea
commit 4c81488187
8 changed files with 153 additions and 327 deletions

View File

@ -4,11 +4,11 @@ import ReactHelper from './require/react-helper';
import ElementsUtil from './require/elements-util';
import BaseElements from './require/base-elements';
import { Channel } from '../data-types';
import createModifyChannelOverlay from './overlay-modify-channel';
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';
export default function createChannel(document: Document, q: Q, ui: UI, guild: CombinedGuild, channel: Channel) {
const element = ReactHelper.createElementFromJSX(
@ -38,10 +38,7 @@ export default function createChannel(document: Document, q: Q, ui: UI, guild: C
if (modifyContextElement.parentElement) {
modifyContextElement.parentElement.removeChild(modifyContextElement);
}
const modifyOverlay = createModifyChannelOverlay(document, q, guild, channel);
document.body.appendChild(modifyOverlay);
q.$$$(modifyOverlay, '.text-input.channel-name').focus();
ElementsUtil.setCursorToEnd(q.$$$(modifyOverlay, '.text-input.channel-name'));
ElementsUtil.presentReactOverlay(document, <ModifyChannelOverlay document={document} guild={guild} channel={channel} />);
});
q.$$$(element, '.modify').addEventListener('mouseenter', () => {

View File

@ -10,7 +10,6 @@ import Q from '../q-module';
import UI from '../ui';
import createCreateInviteTokenOverlay from './overlay-create-invite-token';
import createCreateChannelOverlay from './overlay-create-channel';
import createTokenLogOverlay from './overlay-token-log';
import CombinedGuild from '../guild-combined';

View File

@ -1,155 +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 Globals from "../globals";
import ElementsUtil from "./require/elements-util";
import BaseElements from "./require/base-elements";
import Q from '../q-module';
import CombinedGuild from '../guild-combined';
import React from 'react';
export default function createCreateChannelOverlay(document: Document, q: Q, guild: CombinedGuild): HTMLElement {
// See also overlay-modify-channel
const element = BaseElements.createOverlay(document, (
<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">channel-name</div>
<div className="channel-flavor-divider"></div>
<div className="channel-flavor-text"></div>
</div>
<div className="text-input channel-name" data-placeholder="channel-name" contentEditable={'plaintext-only' as unknown as boolean /* React doesn't have plaintext-only in its typings (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/54779) */}></div>
<div className="text-input channel-flavor-text" data-placeholder="Flavor Text (optional)" contentEditable={'plaintext-only' as unknown as boolean /* React doesn't have plaintext-only in its typings (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/54779) */}></div>
<div className="lower">
<div className="error"></div>
<div className="buttons">
<div className="button submit">Create Channel</div>
</div>
</div>
</div>
));
let newName = '';
let newFlavorText: string | null = '';
function updatePreview() {
newName = q.$$$(element, '.text-input.channel-name').innerText;
newFlavorText = q.$$$(element, '.text-input.channel-flavor-text').innerText;
q.$$$(element, '.channel-title .channel-name').innerText = newName;
q.$$$(element, '.channel-title .channel-flavor-text').innerText = newFlavorText;
if (newFlavorText != '') {
q.$$$(element, '.channel-title .channel-flavor-divider').style.visibility = 'visible';
} else {
q.$$$(element, '.channel-title .channel-flavor-divider').style.visibility = 'hidden';
}
}
updatePreview();
let submitting = false;
async function submit() {
if (submitting) return;
submitting = true;
q.$$$(element, '.error').innerText = '';
q.$$$(element, '.button.submit').innerText = 'Submitting...';
q.$$$(element, '.text-input.channel-name').removeAttribute('contenteditable');
q.$$$(element, '.text-input.channel-flavor-text').removeAttribute('contenteditable');
let success = false;
if (newName.length == 0) {
LOG.warn('attempted to set empty channel name');
q.$$$(element, '.error').innerText = 'Channel name cannot be empty';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (newName.length > Globals.MAX_CHANNEL_NAME_LENGTH) {
LOG.warn('attempted to set too long channel name');
q.$$$(element, '.error').innerText = 'Channel name is too long. ' + newName.length + ' > ' + Globals.MAX_CHANNEL_NAME_LENGTH;
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (!(/^[A-Za-z0-9-]+$/.exec(newName))) {
LOG.warn('attempted to set channel name with illegal characters');
q.$$$(element, '.error').innerText = 'Please use only [A-Za-z0-9-]+ in channel name';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (newFlavorText != null && newFlavorText.length > Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH) {
LOG.warn('attempted to set too long flavor text');
q.$$$(element, '.error').innerText = 'Flavor text is too long. ' + newName.length + ' > ' + Globals.MAX_CHANNEL_NAME_LENGTH;
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else {
if (newFlavorText != null && newFlavorText.length == 0) {
newFlavorText = null;
}
try {
await guild.requestDoCreateChannel(newName, newFlavorText);
success = true;
} catch (e) {
LOG.error('error updating channel', e);
q.$$$(element, '.error').innerText = 'Error updating channel';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
}
}
if (success) {
element.removeSelf();
}
q.$$$(element, '.text-input.channel-name').setAttribute('contenteditable', 'plaintext-only');
q.$$$(element, '.text-input.channel-flavor-text').setAttribute('contenteditable', 'plaintext-only');
submitting = false;
}
const textInputs = q.$$$$(element, '.text-input');
for (const textInput of textInputs) {
textInput.addEventListener('input', () => {
updatePreview();
});
}
q.$$$(element, '.text-input.channel-name').addEventListener('keydown', async (e) => {
if (e.key == 'Backspace' || e.key == 'Escape' || e.key == 'F4') {
// these keys are good
} else if (e.key == 'Tab') {
// have to hard-code this one because otherwise, it just picks the beginning of the next input
e.preventDefault();
e.stopPropagation();
q.$$$(element, '.text-input.channel-flavor-text').focus();
ElementsUtil.setCursorToEnd(q.$$$(element, '.text-input.channel-flavor-text'));
} else if (e.key == 'Enter') {
e.preventDefault();
e.stopPropagation();
await submit();
} else if (!/^[A-Za-z0-9-]$/.exec(e.key)) {
e.preventDefault();
e.stopPropagation();
}
});
q.$$$(element, '.text-input.channel-flavor-text').addEventListener('keydown', async (e) => {
if (e.key == 'Tab' && e.shiftKey) {
// have to hard-code this one because otherwise, it just picks the beginning of the next input
e.preventDefault();
e.stopPropagation();
q.$$$(element, '.text-input.channel-name').focus();
ElementsUtil.setCursorToEnd(q.$$$(element, '.text-input.channel-name'));
} else if (e.key == 'Enter') {
e.preventDefault();
e.stopPropagation();
await submit();
}
});
q.$$$(element, '.button.submit').addEventListener('click', async () => {
await submit();
});
return element;
}

View File

@ -1,162 +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 { Channel } from '../data-types';
import Globals from '../globals.js';
import BaseElements, { HTMLElementWithRemoveSelf } from './require/base-elements.js';
import ElementsUtil from './require/elements-util.js';
import Q from '../q-module';
import CombinedGuild from '../guild-combined';
import React from 'react';
export default function createModifyChannelOverlay(document: Document, q: Q, guild: CombinedGuild, channel: Channel): HTMLElementWithRemoveSelf {
// See also overlay-create-channel
const element = BaseElements.createOverlay(document, (
<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">{channel.name}</div>
<div className="channel-flavor-divider"></div>
<div className="channel-flavor-text">{channel.flavorText || ''}</div>
</div>
<div className="text-input channel-name" data-placeholder="channel-name"
contentEditable={'plaintext-only' as unknown as boolean /* React doesn't have plaintext-only in its typings (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/54779) */}></div>
<div className="text-input channel-flavor-text" data-placeholder="Flavor Text (optional)"
contentEditable={'plaintext-only' as unknown as boolean /* React doesn't have plaintext-only in its typings (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/54779) */}></div>
<div className="lower">
<div className="error"></div>
<div className="buttons">
<div className="button submit">Save Changes</div>
</div>
</div>
</div>
));
q.$$$(element, '.text-input.channel-name').innerText = channel.name;
q.$$$(element, '.text-input.channel-flavor-text').innerText = channel.flavorText || '';
let newName = channel.name;
let newFlavorText = channel.flavorText;
function updatePreview() {
newName = q.$$$(element, '.text-input.channel-name').innerText;
newFlavorText = q.$$$(element, '.text-input.channel-flavor-text').innerText;
q.$$$(element, '.channel-title .channel-name').innerText = newName;
q.$$$(element, '.channel-title .channel-flavor-text').innerText = newFlavorText;
if (newFlavorText != '') {
q.$$$(element, '.channel-title .channel-flavor-divider').style.visibility = 'visible';
} else {
q.$$$(element, '.channel-title .channel-flavor-divider').style.visibility = 'hidden';
}
}
updatePreview();
let submitting = false;
async function submit() {
if (submitting) return;
submitting = true;
q.$$$(element, '.error').innerText = '';
q.$$$(element, '.button.submit').innerText = 'Submitting...';
q.$$$(element, '.text-input.channel-name').removeAttribute('contenteditable');
q.$$$(element, '.text-input.channel-flavor-text').removeAttribute('contenteditable');
let success = false;
if (newName == channel.name && (newFlavorText || '') == (channel.flavorText || '')) {
success = true; // nothing changed
} else if (newName.length == 0) {
LOG.warn('attempted to set empty channel name');
q.$$$(element, '.error').innerText = 'Channel name cannot be empty';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (newName.length > Globals.MAX_CHANNEL_NAME_LENGTH) {
LOG.warn('attempted to set too long channel name');
q.$$$(element, '.error').innerText = 'Channel name is too long. ' + newName.length + ' > ' + Globals.MAX_CHANNEL_NAME_LENGTH;
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (!(/^[A-Za-z0-9-]+$/.exec(newName))) {
LOG.warn('attempted to set channel name with illegal characters');
q.$$$(element, '.error').innerText = 'Please use only [A-Za-z0-9-]+ in channel name';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else if (newFlavorText != null && newFlavorText.length > Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH) {
LOG.warn('attempted to set too long flavor text');
q.$$$(element, '.error').innerText = 'Flavor text is too long. ' + newName.length + ' > ' + Globals.MAX_CHANNEL_NAME_LENGTH;
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
} else {
if (newFlavorText != null && newFlavorText.length == 0) {
newFlavorText = null;
}
try {
await guild.requestDoUpdateChannel(channel.id, newName, newFlavorText);
success = true;
} catch (e) {
LOG.error('error updating channel', e);
q.$$$(element, '.error').innerText = 'Error updating channel';
q.$$$(element, '.button.submit').innerText = 'Try Again';
await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400);
}
}
if (success) {
element.removeSelf();
}
q.$$$(element, '.text-input.channel-name').setAttribute('contenteditable', 'plaintext-only');
q.$$$(element, '.text-input.channel-flavor-text').setAttribute('contenteditable', 'plaintext-only');
submitting = false;
}
const textInputs = q.$$$$(element, '.text-input');
for (const textInput of textInputs) {
textInput.addEventListener('input', () => {
updatePreview();
});
}
q.$$$(element, '.text-input.channel-name').addEventListener('keydown', async (e) => {
if (e.key == 'Backspace' || e.key == 'Escape' || e.key == 'F4') {
// these keys are good
} else if (e.key == 'Tab') {
// have to hard-code this one because otherwise, it just picks the beginning of the next input
e.preventDefault();
e.stopPropagation();
q.$$$(element, '.text-input.channel-flavor-text').focus();
ElementsUtil.setCursorToEnd(q.$$$(element, '.text-input.channel-flavor-text'));
} else if (e.key == 'Enter') {
e.preventDefault();
e.stopPropagation();
await submit();
} else if (!/^[A-Za-z0-9-]$/.exec(e.key)) {
e.preventDefault();
e.stopPropagation();
}
});
q.$$$(element, '.text-input.channel-flavor-text').addEventListener('keydown', async (e) => {
if (e.key == 'Tab' && e.shiftKey) {
// have to hard-code this one because otherwise, it just picks the beginning of the next input
e.preventDefault();
e.stopPropagation();
q.$$$(element, '.text-input.channel-name').focus();
ElementsUtil.setCursorToEnd(q.$$$(element, '.text-input.channel-name'));
} else if (e.key == 'Enter') {
e.preventDefault();
e.stopPropagation();
await submit();
}
});
q.$$$(element, '.button.submit').addEventListener('click', async () => {
await submit();
});
return element;
}

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, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import React, { createRef, FC, useCallback, useEffect, useMemo, useState } from 'react';
import GuildsManager from '../../guilds-manager';
import moment from 'moment';
import TextInput from '../components/input-text';
@ -62,6 +62,12 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
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);
@ -133,6 +139,11 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
}, [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ]);
const onSubmit = useCallback(
ElementsUtil.createShakingOnSubmit({ doSubmit, setShaking, setSubmitFailed, setSubmitting }),
[ doSubmit, shaking, submitFailed, submitFailed, submitting ]
);
return (
<div className="content add-guild">
<div className="preview">
@ -158,10 +169,16 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps)
maxLength={Globals.MAX_DISPLAY_NAME_LENGTH}
value={displayName} setValue={setDisplayName}
setValid={setDisplayNameInputValid} setMessage={setDisplayNameInputMessage}
onEnterKeyDown={() => { submitButtonRef.current?.click(); }}
/>
</div>
</div>
<SubmitOverlayLower buttonMessage="Add Guild" doSubmit={doSubmit} errorMessage={errorMessage} />
<SubmitOverlayLower
ref={submitButtonRef}
buttonMessage="Add Guild"
submitting={submitting} submitFailed={submitFailed} shaking={shaking}
onSubmit={onSubmit} errorMessage={errorMessage}
/>
</div>
);
}

View File

@ -108,7 +108,7 @@ const CreateChannelOverlay: FC<CreateChannelOverlayProps> = (props: CreateChanne
<div className="flavor-text">
<TextInput
ref={flavorTextInputRef}
label="Flavor Text" placeholder="Flavor text (optional)"
label="Flavor Text" placeholder="(optional)"
maxLength={Globals.MAX_CHANNEL_FLAVOR_TEXT_LENGTH}
allowEmpty={true}
value={flavorText} setValue={setFlavorText}

View File

@ -0,0 +1,130 @@
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';
import { Channel } from '../../data-types';
export interface ModifyChannelOverlayProps {
document: Document;
guild: CombinedGuild;
channel: Channel;
}
const ModifyChannelOverlay: FC<ModifyChannelOverlayProps> = (props: ModifyChannelOverlayProps) => {
const { document, guild, channel } = 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>(channel.name);
const [ flavorText, setFlavorText ] = useState<string>(channel.flavorText ?? '');
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.requestDoUpdateChannel(channel.id, 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={channel.flavorText ?? '(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 ModifyChannelOverlay;

View File

@ -398,7 +398,7 @@ function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity:
bindEvent(
client, identity,
{ verified: true, privileges: [ 'modify_channels' ] },
'update-channel', [ 'string', 'string', 'string', 'function' ],
'update-channel', [ 'string', 'string', 'string?', 'function' ],
async (channelId, name, flavorText, respond) => {
if (!identity.guildId) throw new EventError('identity no guildId');