From 6fe10160476efccd12cdda4919eeb00a728cf7bc Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Thu, 30 Dec 2021 21:28:30 -0600 Subject: [PATCH] Convert ReactHelper to just export functions --- .../elements/components/infinite-scroll.tsx | 4 +- .../elements/components/input-image-edit.tsx | 6 +- .../webapp/elements/components/overlay.tsx | 4 +- .../webapp/elements/components/retry.tsx | 4 +- .../contexts/components/context-menu.tsx | 4 +- .../elements/contexts/components/context.tsx | 4 +- .../elements/contexts/context-menu-image.tsx | 6 +- .../displays/display-guild-invites.tsx | 4 +- .../lists/components/channel-element.tsx | 4 +- .../lists/components/guild-list-element.tsx | 4 +- .../lists/components/message-element.tsx | 6 +- .../elements/overlays/overlay-add-guild.tsx | 6 +- .../elements/overlays/overlay-channel.tsx | 4 +- .../elements/overlays/overlay-personalize.tsx | 4 +- .../elements/require/guild-subscriptions.ts | 12 +- .../webapp/elements/require/react-helper.tsx | 923 +++++++++--------- .../elements/sections/connection-info.tsx | 4 +- .../sections/guild-list-container.tsx | 6 +- .../webapp/elements/sections/guild-title.tsx | 4 +- .../webapp/elements/sections/send-message.tsx | 10 +- 20 files changed, 504 insertions(+), 519 deletions(-) diff --git a/src/client/webapp/elements/components/infinite-scroll.tsx b/src/client/webapp/elements/components/infinite-scroll.tsx index c55156b..87fda83 100644 --- a/src/client/webapp/elements/components/infinite-scroll.tsx +++ b/src/client/webapp/elements/components/infinite-scroll.tsx @@ -1,5 +1,5 @@ import React, { Dispatch, FC, ReactNode, SetStateAction, useEffect } from 'react'; -import ReactHelper from '../require/react-helper'; +import { useColumnReverseInfiniteScroll } from '../require/react-helper'; import Retry from './retry'; export interface InfiniteScrollProps { @@ -39,7 +39,7 @@ const InfiniteScroll: FC = (props: InfiniteScrollProps) => onLoadCallable, fetchAboveRetry, fetchBelowRetry - ] = ReactHelper.useColumnReverseInfiniteScroll( + ] = useColumnReverseInfiniteScroll( 600, fetchAboveCallable, fetchBelowCallable, diff --git a/src/client/webapp/elements/components/input-image-edit.tsx b/src/client/webapp/elements/components/input-image-edit.tsx index f5f9a63..235f473 100644 --- a/src/client/webapp/elements/components/input-image-edit.tsx +++ b/src/client/webapp/elements/components/input-image-edit.tsx @@ -7,7 +7,7 @@ import React, { FC, useEffect, useRef } from 'react'; import ElementsUtil from '../require/elements-util'; import * as FileType from 'file-type'; -import ReactHelper from '../require/react-helper'; +import { useIsMountedRef, useOneTimeAsyncAction } from '../require/react-helper'; interface ImageEditInputProps { @@ -27,11 +27,11 @@ const ImageEditInput: FC = (props: ImageEditInputProps) => const acceptedExtTypes = [ 'png', 'jpg', 'jpeg' ]; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const imgRef = useRef(null); - const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction( + const [ imgSrc ] = useOneTimeAsyncAction( async () => { if (!value) { setValid(false); diff --git a/src/client/webapp/elements/components/overlay.tsx b/src/client/webapp/elements/components/overlay.tsx index e3aa0ce..f909719 100644 --- a/src/client/webapp/elements/components/overlay.tsx +++ b/src/client/webapp/elements/components/overlay.tsx @@ -4,7 +4,7 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import React, { FC, RefObject, useCallback, useEffect } from "react"; -import ReactHelper from '../require/react-helper'; +import { useCloseWhenEscapeOrClickedOrContextOutsideEffect } from '../require/react-helper'; interface OverlayProps { childRootRef?: RefObject; // clicks outside this ref will close the overlay @@ -15,7 +15,7 @@ const Overlay: FC = (props: OverlayProps) => { const { childRootRef, close, children } = props; if (childRootRef) { - ReactHelper.useCloseWhenEscapeOrClickedOrContextOutsideEffect(childRootRef, close); + useCloseWhenEscapeOrClickedOrContextOutsideEffect(childRootRef, close); } const keyDownHandler = useCallback((e: KeyboardEvent) => { diff --git a/src/client/webapp/elements/components/retry.tsx b/src/client/webapp/elements/components/retry.tsx index 2259b9a..481b55f 100644 --- a/src/client/webapp/elements/components/retry.tsx +++ b/src/client/webapp/elements/components/retry.tsx @@ -4,7 +4,7 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import React, { FC } from 'react'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton } from '../require/react-helper'; import Button from './button'; export interface RetryProps { @@ -16,7 +16,7 @@ export interface RetryProps { const Retry: FC = (props: RetryProps) => { const { error, text, retryFunc } = props; - const [ retryCallable, buttonText, buttonShaking ] = ReactHelper.useAsyncSubmitButton( + const [ retryCallable, buttonText, buttonShaking ] = useAsyncSubmitButton( async () => { await retryFunc(); // error handled by effect return { result: null, errorMessage: null }; diff --git a/src/client/webapp/elements/contexts/components/context-menu.tsx b/src/client/webapp/elements/contexts/components/context-menu.tsx index 9b808b9..8935bca 100644 --- a/src/client/webapp/elements/contexts/components/context-menu.tsx +++ b/src/client/webapp/elements/contexts/components/context-menu.tsx @@ -1,6 +1,6 @@ import React, { FC, ReactNode, RefObject, useRef } from 'react' import { IAlignment } from '../../require/elements-util'; -import ReactHelper from '../../require/react-helper'; +import { useCloseWhenEscapeOrClickedOrContextOutsideEffect } from '../../require/react-helper'; import Context from './context'; export interface ContextMenuProps { @@ -17,7 +17,7 @@ const ContextMenu: FC = (props: ContextMenuProps) => { const rootRef = useRef(null); - ReactHelper.useCloseWhenEscapeOrClickedOrContextOutsideEffect(rootRef, close); + useCloseWhenEscapeOrClickedOrContextOutsideEffect(rootRef, close); return ( diff --git a/src/client/webapp/elements/contexts/components/context.tsx b/src/client/webapp/elements/contexts/components/context.tsx index 10e5859..79245ff 100644 --- a/src/client/webapp/elements/contexts/components/context.tsx +++ b/src/client/webapp/elements/contexts/components/context.tsx @@ -1,6 +1,6 @@ import React, { FC, ReactNode, RefObject, useRef } from 'react'; import { IAlignment } from '../../require/elements-util'; -import ReactHelper from '../../require/react-helper'; +import { useAlignment } from '../../require/react-helper'; export interface ContextProps { rootRef?: RefObject; @@ -16,7 +16,7 @@ const Context: FC = (props: ContextProps) => { const myRootRef = useRef(null); - const [ className ] = ReactHelper.useAlignment( + const [ className ] = useAlignment( rootRef ?? myRootRef, relativeToRef ?? null, relativeToPos ?? null, alignment, 'context react' ); diff --git a/src/client/webapp/elements/contexts/context-menu-image.tsx b/src/client/webapp/elements/contexts/context-menu-image.tsx index 087fb70..4e20452 100644 --- a/src/client/webapp/elements/contexts/context-menu-image.tsx +++ b/src/client/webapp/elements/contexts/context-menu-image.tsx @@ -1,6 +1,6 @@ import React, { FC, useMemo } from 'react'; import * as electron from 'electron'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton, useDownloadButton } from '../require/react-helper'; import ContextMenu from './components/context-menu'; import * as FileType from 'file-type'; import sharp from 'sharp'; @@ -20,7 +20,7 @@ const ImageContextMenu: FC = (props: ImageContextMenuProp // TODO: Integrate shaking - const [ copyCallable, copyText, copyShaking ] = ReactHelper.useAsyncSubmitButton( + const [ copyCallable, copyText, copyShaking ] = useAsyncSubmitButton( async () => { const type = await FileType.fromBuffer(resourceBuff); if (!type) { @@ -45,7 +45,7 @@ const ImageContextMenu: FC = (props: ImageContextMenuProp } ); - const [ saveCallable, saveText, saveShaking ] = ReactHelper.useDownloadButton( + const [ saveCallable, saveText, saveShaking ] = useDownloadButton( resourceName, resourceBuff, { start: 'Save Image' + previewText, diff --git a/src/client/webapp/elements/displays/display-guild-invites.tsx b/src/client/webapp/elements/displays/display-guild-invites.tsx index 357a8f0..14dbbe9 100644 --- a/src/client/webapp/elements/displays/display-guild-invites.tsx +++ b/src/client/webapp/elements/displays/display-guild-invites.tsx @@ -8,7 +8,7 @@ import CombinedGuild from '../../guild-combined'; import Display from '../components/display'; import InvitePreview from '../components/invite-preview'; import { Member, Token } from '../../data-types'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton } from '../require/react-helper'; import { Duration } from 'moment'; import moment from 'moment'; import DropdownInput from '../components/input-dropdown'; @@ -44,7 +44,7 @@ const GuildInvitesDisplay: FC = (props: GuildInvitesDi } }, [ expiresFromNowText ]); - const [ createTokenFunc, tokenButtonText, tokenButtonShaking, _, createTokenFailMessage ] = ReactHelper.useAsyncSubmitButton( + const [ createTokenFunc, tokenButtonText, tokenButtonShaking, _, createTokenFailMessage ] = useAsyncSubmitButton( async () => { try { const createdToken = await guild.requestDoCreateToken(expiresFromNowText === 'never' ? null : expiresFromNowText) diff --git a/src/client/webapp/elements/lists/components/channel-element.tsx b/src/client/webapp/elements/lists/components/channel-element.tsx index 2fd964e..3ad958a 100644 --- a/src/client/webapp/elements/lists/components/channel-element.tsx +++ b/src/client/webapp/elements/lists/components/channel-element.tsx @@ -8,7 +8,7 @@ import { Channel, Member } from '../../../data-types'; import CombinedGuild from '../../../guild-combined'; import ChannelOverlay from '../../overlays/overlay-channel'; import BaseElements from '../../require/base-elements'; -import ReactHelper from '../../require/react-helper'; +import { useContextHover } from '../../require/react-helper'; import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic'; export interface ChannelElementProps { @@ -27,7 +27,7 @@ const ChannelElement: FC = (props: ChannelElementProps) => const baseClassName = activeChannel?.id === channel.id ? 'channel text active' : 'channel text'; - const [ modifyContextHover, modifyMouseEnterCallable, modifyMouseLeaveCallable ] = ReactHelper.useContextHover( + const [ modifyContextHover, modifyMouseEnterCallable, modifyMouseLeaveCallable ] = useContextHover( () => { return ( diff --git a/src/client/webapp/elements/lists/components/guild-list-element.tsx b/src/client/webapp/elements/lists/components/guild-list-element.tsx index c8705b1..c3d4567 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -6,7 +6,7 @@ import BasicHover, { BasicHoverSide } from '../../contexts/context-hover-basic'; import BaseElements from '../../require/base-elements'; import { IAlignment } from '../../require/elements-util'; import GuildSubscriptions from '../../require/guild-subscriptions'; -import ReactHelper, { useContextClickContextMenu } from '../../require/react-helper'; +import { useContextClickContextMenu, useContextHover } from '../../require/react-helper'; export interface GuildListElementProps { guildsManager: GuildsManager; @@ -26,7 +26,7 @@ const GuildListElement: FC = (props: GuildListElementProp const [ selfMember ] = GuildSubscriptions.useSelfMemberSubscription(guild); const [ iconSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, guildMeta?.iconResourceId ?? null); - const [ contextHover, mouseEnterCallable, mouseLeaveCallable ] = ReactHelper.useContextHover(() => { + const [ contextHover, mouseEnterCallable, mouseLeaveCallable ] = useContextHover(() => { if (!guildMeta) return null; if (!selfMember) return null; const nameStyle = selfMember.roleColor ? { color: selfMember.roleColor } : {}; diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index 4984ffe..74a145e 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -6,7 +6,7 @@ import ImageContextMenu from '../../contexts/context-menu-image'; import ImageOverlay from '../../overlays/overlay-image'; import ElementsUtil, { IAlignment } from '../../require/elements-util'; import GuildSubscriptions from '../../require/guild-subscriptions'; -import ReactHelper, { useContextClickContextMenu } from '../../require/react-helper'; +import { useContextClickContextMenu, useDownloadButton, useOneTimeAsyncAction } from '../../require/react-helper'; interface ResourceElementProps { guild: CombinedGuild; @@ -17,7 +17,7 @@ interface ResourceElementProps { const ResourceElement: FC = (props: ResourceElementProps) => { const { guild, resourceId, resourceName } = props; - const [ callable, text, shaking ] = ReactHelper.useDownloadButton( + const [ callable, text, shaking ] = useDownloadButton( resourceName, { guild, resourceId }, { @@ -94,7 +94,7 @@ const MessageElement: FC = (props: MessageElementProps) => return message.isContinued(prevMessage) ? 'message-react continued' : 'message-react'; }, [ message, prevMessage ]); - const [ avatarSrc ] = ReactHelper.useOneTimeAsyncAction( + const [ avatarSrc ] = useOneTimeAsyncAction( async () => { if (message.isContinued(prevMessage)) return './img/loading.svg'; // don't load for elements that won't use it if (!(message.member instanceof Member)) return './img/error.png'; // TODO: Make this a nicer 'unknown' image diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index fe0625e..0d2171b 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -13,7 +13,7 @@ import SubmitOverlayLower from '../components/submit-overlay-lower'; import path from 'path'; import CombinedGuild from '../../guild-combined'; import InvitePreview from '../components/invite-preview'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton, useOneTimeAsyncAction } from '../require/react-helper'; import * as fs from 'fs/promises'; import Button from '../components/button'; import Overlay from '../components/overlay'; @@ -74,7 +74,7 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); const [ avatarInputValid, setAvatarInputValid ] = useState(false); - const [ exampleAvatarBuff, exampleAvatarBuffError ] = ReactHelper.useOneTimeAsyncAction( + const [ exampleAvatarBuff, exampleAvatarBuffError ] = useOneTimeAsyncAction( async () => await fs.readFile(exampleAvatarPath), null, [ exampleAvatarPath ] @@ -93,7 +93,7 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) return null; }, [ exampleAvatarBuffError, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); - const [ submitFunc, submitButtonText, submitButtonShaking, _, submitFailMessage ] = ReactHelper.useAsyncSubmitButton( + const [ submitFunc, submitButtonText, submitButtonShaking, _, submitFailMessage ] = useAsyncSubmitButton( async () => { if (validationErrorMessage || !avatarBuff) return { result: null, errorMessage: 'Validation failed' }; if (expired) return { result: null, errorMessage: 'Token expired' }; diff --git a/src/client/webapp/elements/overlays/overlay-channel.tsx b/src/client/webapp/elements/overlays/overlay-channel.tsx index 06cc787..7319c49 100644 --- a/src/client/webapp/elements/overlays/overlay-channel.tsx +++ b/src/client/webapp/elements/overlays/overlay-channel.tsx @@ -10,7 +10,7 @@ import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import Globals from '../../globals'; import { Channel } from '../../data-types'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; @@ -61,7 +61,7 @@ const ChannelOverlay: FC = (props: ChannelOverlayProps) => return null; }, [ nameInputValid, nameInputMessage, flavorTextInputValid, flavorTextInputMessage ]); - const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = ReactHelper.useAsyncSubmitButton( + const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = useAsyncSubmitButton( async () => { if (validationErrorMessage) return { result: null, errorMessage: 'Invalid input' }; diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 09f3fee..8df393e 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -11,7 +11,7 @@ import ImageEditInput from '../components/input-image-edit'; import TextInput from '../components/input-text'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import GuildSubscriptions from '../require/guild-subscriptions'; -import ReactHelper from '../require/react-helper'; +import { useAsyncSubmitButton } from '../require/react-helper'; import Button from '../components/button'; import Overlay from '../components/overlay'; @@ -66,7 +66,7 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl return null; }, [ displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage ]); - const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = ReactHelper.useAsyncSubmitButton( + const [ submitFunc, submitButtonText, submitButtonShaking, submitFailMessage ] = useAsyncSubmitButton( async (isMounted: MutableRefObject) => { if (validationErrorMessage) return { result: null, errorMessage: 'Invalid input' }; diff --git a/src/client/webapp/elements/require/guild-subscriptions.ts b/src/client/webapp/elements/require/guild-subscriptions.ts index 7412674..58b0877 100644 --- a/src/client/webapp/elements/require/guild-subscriptions.ts +++ b/src/client/webapp/elements/require/guild-subscriptions.ts @@ -12,7 +12,7 @@ import { Conflictable, Connectable } from "../../guild-types"; import { EventEmitter } from 'tsee'; import { IDQuery, PartialMessageListQuery } from '../../auto-verifier-with-args'; import { Token, Channel } from '../../data-types'; -import ReactHelper from './react-helper'; +import { useIsMountedRef, useOneTimeAsyncAction } from './react-helper'; import Globals from '../../globals'; import ElementsUtil from './elements-util'; @@ -76,7 +76,7 @@ export default class GuildSubscriptions { ): [ fetchRetryCallable: () => Promise ] { const { guild, onFetch, onFetchError, bindEventsFunc, unbindEventsFunc } = subscriptionParams; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const fetchManagerFunc = useCallback(async () => { if (!isMounted.current) return; @@ -116,7 +116,7 @@ export default class GuildSubscriptions { ): [value: T | null, fetchError: unknown | null, events: EventEmitter] { const { updatedEventName, updatedEventArgsMap, conflictEventName, conflictEventArgsMap } = eventMappingParams; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const [ fetchError, setFetchError ] = useState(null); const [ value, setValue ] = useState(null); @@ -202,7 +202,7 @@ export default class GuildSubscriptions { sortFunc } = eventMappingParams; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const [ fetchError, setFetchError ] = useState(null); const [ value, setValue ] = useState(null); @@ -335,7 +335,7 @@ export default class GuildSubscriptions { sortFunc } = eventMappingParams; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const [ value, setValue ] = useState(null); @@ -577,7 +577,7 @@ export default class GuildSubscriptions { static useSoftImageSrcResourceSubscription(guild: CombinedGuild, resourceId: string | null): [ imgSrc: string, resource: Resource | null, fetchError: unknown | null ] { const [ resource, fetchError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId); - const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction( + const [ imgSrc ] = useOneTimeAsyncAction( async () => { if (fetchError) return './img/error.png'; if (!resource) return './img/loading.svg'; diff --git a/src/client/webapp/elements/require/react-helper.tsx b/src/client/webapp/elements/require/react-helper.tsx index c34a373..be500a6 100644 --- a/src/client/webapp/elements/require/react-helper.tsx +++ b/src/client/webapp/elements/require/react-helper.tsx @@ -4,7 +4,6 @@ import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); import { DependencyList, Dispatch, MutableRefObject, ReactNode, RefObject, SetStateAction, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ReactDOMServer from "react-dom/server"; import { ShouldNeverHappenError } from "../../data-types"; import Util from '../../util'; import CombinedGuild from '../../guild-combined'; @@ -16,533 +15,459 @@ import ElementsUtil, { IAlignment } from './elements-util'; import React from 'react'; import FileDropTarget from '../components/file-drop-target'; -// Helper function so we can use JSX before fully committing to React +export function useIsMountedRef() { + const isMounted = useRef(false); + useEffect(() => { + isMounted.current = true; + return () => { isMounted.current = false; } + }); + return isMounted; +} -export default class ReactHelper { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static createElementFromJSX(element: JSX.Element): Element { - // See also: https://www.codegrepper.com/code-examples/javascript/convert+a+string+to+html+element+in+js - const htmlString = ReactDOMServer.renderToStaticMarkup(element); - const parser = new DOMParser(); - const document = parser.parseFromString(htmlString, 'text/html'); - if (!document.body.firstElementChild) throw new ShouldNeverHappenError('no children in react markup!'); - return document.body.firstElementChild; - } +function useShake(ms: number): [ shaking: boolean, doShake: () => void ] { + const isMounted = useIsMountedRef(); - static useIsMountedRef() { - const isMounted = useRef(false); - useEffect(() => { - isMounted.current = true; - return () => { isMounted.current = false; } - }); - return isMounted; - } + const [ shaking, setShaking ] = useState(false); - static useShake(ms: number): [ shaking: boolean, doShake: () => void ] { - const isMounted = ReactHelper.useIsMountedRef(); + const doShake = useCallback(async () => { + if (!isMounted.current) return; + if (shaking) return; + setShaking(true); + await Util.sleep(ms); + if (!isMounted.current) return; + setShaking(false); + }, [ ms ]); - const [ shaking, setShaking ] = useState(false); + return [ shaking, doShake ]; +} - const doShake = useCallback(async () => { - if (!isMounted.current) return; - if (shaking) return; - setShaking(true); - await Util.sleep(ms); - if (!isMounted.current) return; - setShaking(false); - }, [ ms ]); +// Runs an Async action one time (updates when deps changes) +export function useOneTimeAsyncAction( + actionFunc: () => Promise, + initialValue: V, + deps: DependencyList +): [ value: T | V, error: unknown | null ] { + const isMounted = useRef(false); - return [ shaking, doShake ]; - } + const [ value, setValue ] = useState(initialValue); + const [ error, setError ] = useState(null); - // Runs an Async action one time (updates when deps changes) - static useOneTimeAsyncAction( - actionFunc: () => Promise, - initialValue: V, - deps: DependencyList - ): [ value: T | V, error: unknown | null ] { - const isMounted = useRef(false); + useEffect(() => { + isMounted.current = true; - const [ value, setValue ] = useState(initialValue); - const [ error, setError ] = useState(null); - - useEffect(() => { - isMounted.current = true; - - (async () => { - try { - const value = await actionFunc(); - if (!isMounted.current) return; - setValue(value); - setError(null); - } catch (e: unknown) { - LOG.error('unable to perform async action subscription', e); - if (!isMounted.current) return; - setError(e); - } - })(); - - return () => { isMounted.current = false; }; - }, deps); - - return [ value, error ]; - } - - static useAsyncCallback( - actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null, result: ResultType | null }>, - deps: DependencyList - ): [ callable: () => void, result: ResultType | null, errorMessage: string | null, shaking: boolean ] { - const isMounted = ReactHelper.useIsMountedRef(); - - const [ result, setResult ] = useState(null); - const [ errorMessage, setErrorMessage ] = useState(null); - const [ shaking, doShake ] = ReactHelper.useShake(400); - - const [ pending, setPending ] = useState(false); - - const callable = useCallback(async () => { - if (!isMounted.current) return; - if (pending) return; - setErrorMessage(null); - setPending(true); + (async () => { try { - const { result, errorMessage } = await actionFunc(isMounted); + const value = await actionFunc(); if (!isMounted.current) return; - setResult(result); - setErrorMessage(errorMessage); - setPending(false); - if (errorMessage) doShake(); + setValue(value); + setError(null); } catch (e: unknown) { - LOG.error('unable to perform submit button actionFunc', e); - setResult(null); - setErrorMessage('Unknown error'); - if (errorMessage) doShake(); - setPending(false); + LOG.error('unable to perform async action subscription', e); + if (!isMounted.current) return; + setError(e); } - }, [ ...deps, pending, actionFunc ]); + })(); - return [ callable, result, errorMessage, shaking ]; - } + return () => { isMounted.current = false; }; + }, deps); - static useAsyncVoidCallback( - actionFunc: (isMounted: MutableRefObject) => Promise, - deps: DependencyList - ): [ callable: () => void ] { - const [ callable ] = ReactHelper.useAsyncCallback( - async (isMounted: MutableRefObject) => { - await actionFunc(isMounted); - return { errorMessage: null, result: null }; - }, - deps - ); - return [ callable ]; - } + return [ value, error ]; +} - static useDownloadButton( - downloadName: string, - downloadSrc: { guild: CombinedGuild, resourceId: string } | Buffer, - stateTextMapping?: { start?: string, pendingFetch?: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string } - ): [ callable: () => void, text: string, shaking: boolean ] { - const textMapping = { ...{ start: 'Download', pendingFetch: 'Downloading...', errorFetch: 'Try Again', pendingSave: 'Saving...', errorSave: 'Try Again', success: 'Open in Explorer' }, ...stateTextMapping }; +export function useAsyncCallback( + actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null, result: ResultType | null }>, + deps: DependencyList +): [ callable: () => void, result: ResultType | null, errorMessage: string | null, shaking: boolean ] { + const isMounted = useIsMountedRef(); - const downloadBuff = downloadSrc instanceof Buffer ? downloadSrc : null; + const [ result, setResult ] = useState(null); + const [ errorMessage, setErrorMessage ] = useState(null); + const [ shaking, doShake ] = useShake(400); - const [ filePath, setFilePath ] = useState(null); - const [ fileBuffer, setFileBuffer ] = useState(downloadBuff); + const [ pending, setPending ] = useState(false); - const [ text, setText ] = useState(textMapping.start); - const [ shaking, doShake ] = ReactHelper.useShake(400); + const callable = useCallback(async () => { + if (!isMounted.current) return; + if (pending) return; + setErrorMessage(null); + setPending(true); + try { + const { result, errorMessage } = await actionFunc(isMounted); + if (!isMounted.current) return; + setResult(result); + setErrorMessage(errorMessage); + setPending(false); + if (errorMessage) doShake(); + } catch (e: unknown) { + LOG.error('unable to perform submit button actionFunc', e); + setResult(null); + setErrorMessage('Unknown error'); + if (errorMessage) doShake(); + setPending(false); + } + }, [ ...deps, pending, actionFunc ]); - const [ callable ] = ReactHelper.useAsyncVoidCallback( - async (isMounted: MutableRefObject) => { - if (shaking) return; + return [ callable, result, errorMessage, shaking ]; +} - let savePath = filePath; +export function useAsyncVoidCallback( + actionFunc: (isMounted: MutableRefObject) => Promise, + deps: DependencyList +): [ callable: () => void ] { + const [ callable ] = useAsyncCallback( + async (isMounted: MutableRefObject) => { + await actionFunc(isMounted); + return { errorMessage: null, result: null }; + }, + deps + ); + return [ callable ]; +} - if (savePath !== null) { - const exists = await Util.exists(savePath); - if (!isMounted.current) return; - if (exists) { - electron.shell.showItemInFolder(savePath); - return; - } else { - setFilePath(null); - savePath = null; - } +export function useDownloadButton( + downloadName: string, + downloadSrc: { guild: CombinedGuild, resourceId: string } | Buffer, + stateTextMapping?: { start?: string, pendingFetch?: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string } +): [ callable: () => void, text: string, shaking: boolean ] { + const textMapping = { ...{ start: 'Download', pendingFetch: 'Downloading...', errorFetch: 'Try Again', pendingSave: 'Saving...', errorSave: 'Try Again', success: 'Open in Explorer' }, ...stateTextMapping }; + + const downloadBuff = downloadSrc instanceof Buffer ? downloadSrc : null; + + const [ filePath, setFilePath ] = useState(null); + const [ fileBuffer, setFileBuffer ] = useState(downloadBuff); + + const [ text, setText ] = useState(textMapping.start); + const [ shaking, doShake ] = useShake(400); + + const [ callable ] = useAsyncVoidCallback( + async (isMounted: MutableRefObject) => { + if (shaking) return; + + let savePath = filePath; + + if (savePath !== null) { + const exists = await Util.exists(savePath); + if (!isMounted.current) return; + if (exists) { + electron.shell.showItemInFolder(savePath); + return; + } else { + setFilePath(null); + savePath = null; } + } - let saveBuffer = fileBuffer; - if (saveBuffer === null) { - if (!(downloadSrc instanceof Buffer)) { - // fetch the buffer - const { guild, resourceId } = downloadSrc; - try { - setText(textMapping.pendingFetch); - const resource = await guild.fetchResource(resourceId); - if (!isMounted.current) return; - setFileBuffer(resource.data); - saveBuffer = resource.data; - } catch (e: unknown) { - LOG.error('Error fetching resource for download button', e); - if (!isMounted.current) return; - setText(textMapping.errorFetch); - doShake(); - return; - } - } else { - // Save buffer not specified - LOG.error('Bad download setup'); + let saveBuffer = fileBuffer; + if (saveBuffer === null) { + if (!(downloadSrc instanceof Buffer)) { + // fetch the buffer + const { guild, resourceId } = downloadSrc; + try { + setText(textMapping.pendingFetch); + const resource = await guild.fetchResource(resourceId); + if (!isMounted.current) return; + setFileBuffer(resource.data); + saveBuffer = resource.data; + } catch (e: unknown) { + LOG.error('Error fetching resource for download button', e); + if (!isMounted.current) return; setText(textMapping.errorFetch); doShake(); return; } + } else { + // Save buffer not specified + LOG.error('Bad download setup'); + setText(textMapping.errorFetch); + doShake(); + return; } - - if (savePath === null) { - try { - const availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, downloadName); - if (!isMounted.current) return; - const availablePath = path.join(Globals.DOWNLOAD_DIR, availableName); - await fs.writeFile(availablePath, saveBuffer); - if (!isMounted.current) return; - setFilePath(availablePath); - savePath = availablePath; - } catch (e: unknown) { - LOG.error('Error writing download file', e); - if (!isMounted.current) return; - setText(textMapping.errorSave); - doShake(); - return; - } - } - - setText(textMapping.success); - }, - [ downloadName, downloadSrc, filePath, fileBuffer ] - ); - - return [ callable, text, shaking ]; - } - - static useAsyncSubmitButton( - actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null, result: ResultType | null }>, - deps: DependencyList, - stateTextMapping?: { start?: string, pending?: string, error?: string, done?: string } - ): [ callable: () => void, text: string, shaking: boolean, result: ResultType | null, errorMessage: string | null ] { - const textMapping = { ...{ start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, ...stateTextMapping } - - const [ pending, setPending ] = useState(false); - const [ complete, setComplete ] = useState(false); - - const [ callable, result, errorMessage, shaking ] = ReactHelper.useAsyncCallback( - async (isMounted: MutableRefObject) => { + } + + if (savePath === null) { try { - setPending(true); - const actionReturnValue = await actionFunc(isMounted); - if (!isMounted) return { errorMessage: null, result: null }; - setPending(false); - if (actionReturnValue.errorMessage === null) setComplete(true); - return actionReturnValue; - } catch (e) { - LOG.error('Error submitting button', e); - if (!isMounted) return { errorMessage: null, result: null }; - setPending(false); - return { errorMessage: 'Error submitting button', result: null }; + const availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, downloadName); + if (!isMounted.current) return; + const availablePath = path.join(Globals.DOWNLOAD_DIR, availableName); + await fs.writeFile(availablePath, saveBuffer); + if (!isMounted.current) return; + setFilePath(availablePath); + savePath = availablePath; + } catch (e: unknown) { + LOG.error('Error writing download file', e); + if (!isMounted.current) return; + setText(textMapping.errorSave); + doShake(); + return; } - - - }, - [ ...deps, actionFunc ] - ); - - const text = useMemo(() => { - if (errorMessage) return textMapping.error; - if (pending) return textMapping.pending; - if (complete) return textMapping.done; - return textMapping.start; - }, [ pending, complete, errorMessage ]); - - return [ callable, text, shaking, result, errorMessage ]; - } - - static useColumnReverseInfiniteScroll( - threshold: number, - loadMoreAbove: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>, - loadMoreBelow: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>, - setScrollRatio: Dispatch> - ): [ - updateCallable: (event: UIEvent) => void, - onLoadCallable: (params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => void, - loadAboveRetry: () => Promise, - loadBelowRetry: () => Promise - ] { - const isMounted = ReactHelper.useIsMountedRef(); - - const [ loadingAbove, setLoadingAbove ] = useState(false); - const [ loadingBelow, setLoadingBelow ] = useState(false); - - const [ hasMoreAbove, setHasMoreAbove ] = useState(false); - const [ hasMoreBelow, setHasMoreBelow ] = useState(false); - - const loadAbove = useCallback(async () => { - if (loadingAbove || !hasMoreAbove) return; - setLoadingAbove(true); - const loadResult = await loadMoreAbove(); - if (!isMounted.current) return; - setHasMoreAbove(loadResult.hasMoreAbove); - setHasMoreBelow(oldHasMoreBelow => oldHasMoreBelow || loadResult.removedFromBottom); - setLoadingAbove(false); - }, [ loadingAbove, hasMoreAbove, loadMoreAbove ]); - - const loadBelow = useCallback(async () => { - if (loadingBelow || !hasMoreBelow) return; - setLoadingBelow(true); - const loadResult = await loadMoreBelow(); - if (!isMounted.current) return; - setHasMoreBelow(loadResult.hasMoreBelow); - setHasMoreAbove(oldHasMoreAbove => oldHasMoreAbove || loadResult.removedFromTop); - setLoadingBelow(false); - }, [ loadingBelow, hasMoreBelow, loadMoreBelow ]); - - const onScrollCallable = useCallback(async (event: UIEvent) => { - const scrollTop = event.currentTarget.scrollTop; - const scrollHeight = event.currentTarget.scrollHeight; - const clientHeight = event.currentTarget.clientHeight; - - // WARNING - // There's likely an inconsistency between browsers on this so have fun when you're working - // on the cross-platform implementation of this - // scrollTop apparantly is negative for column-reverse divs (this actually kindof makes sense if you flip your head upside down) - // have to reverse this - // I expect this was a change with some version of chromium. - // MDN documentation issue: https://github.com/mdn/content/issues/10968 - - setScrollRatio(Math.abs(scrollTop / scrollHeight)); - - const distToTop = -(clientHeight - scrollHeight - scrollTop); // keep in mind scrollTop is negative >:] - const distToBottom = -scrollTop; - - //LOG.debug(`scroll callable update. to top: ${distToTop}, to bottom: ${distToBottom}`) - - if (distToTop < threshold) { - await loadAbove(); } - if (distToBottom < threshold) { - await loadBelow(); + setText(textMapping.success); + }, + [ downloadName, downloadSrc, filePath, fileBuffer ] + ); + + return [ callable, text, shaking ]; +} + +export function useAsyncSubmitButton( + actionFunc: (isMounted: MutableRefObject) => Promise<{ errorMessage: string | null, result: ResultType | null }>, + deps: DependencyList, + stateTextMapping?: { start?: string, pending?: string, error?: string, done?: string } +): [ callable: () => void, text: string, shaking: boolean, result: ResultType | null, errorMessage: string | null ] { + const textMapping = { ...{ start: 'Submit', pending: 'Submitting...', error: 'Try Again', done: 'Done' }, ...stateTextMapping } + + const [ pending, setPending ] = useState(false); + const [ complete, setComplete ] = useState(false); + + const [ callable, result, errorMessage, shaking ] = useAsyncCallback( + async (isMounted: MutableRefObject) => { + try { + setPending(true); + const actionReturnValue = await actionFunc(isMounted); + if (!isMounted) return { errorMessage: null, result: null }; + setPending(false); + if (actionReturnValue.errorMessage === null) setComplete(true); + return actionReturnValue; + } catch (e) { + LOG.error('Error submitting button', e); + if (!isMounted) return { errorMessage: null, result: null }; + setPending(false); + return { errorMessage: 'Error submitting button', result: null }; } - }, [ setScrollRatio, loadAbove, loadBelow, threshold ]); + + + }, + [ ...deps, actionFunc ] + ); - const onLoadCallable = useCallback((params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => { - setHasMoreAbove(params.hasMoreAbove); - setHasMoreBelow(params.hasMoreBelow); - }, []); + const text = useMemo(() => { + if (errorMessage) return textMapping.error; + if (pending) return textMapping.pending; + if (complete) return textMapping.done; + return textMapping.start; + }, [ pending, complete, errorMessage ]); - return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ]; - } + return [ callable, text, shaking, result, errorMessage ]; +} - // Makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside - static useCloseWhenEscapeOrClickedOrContextOutsideEffect(ref: RefObject, close: () => void) { - // Have to use a ref here and not states since we can't re-assign state between mouseup and click - const mouseRef = useRef<{ mouseDownTarget: Node | null, mouseUpTarget: Node | null}>({ mouseDownTarget: null, mouseUpTarget: null }); +export function useColumnReverseInfiniteScroll( + threshold: number, + loadMoreAbove: () => Promise<{ hasMoreAbove: boolean, removedFromBottom: boolean }>, + loadMoreBelow: () => Promise<{ hasMoreBelow: boolean, removedFromTop: boolean }>, + setScrollRatio: Dispatch> +): [ + updateCallable: (event: UIEvent) => void, + onLoadCallable: (params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => void, + loadAboveRetry: () => Promise, + loadBelowRetry: () => Promise +] { + const isMounted = useIsMountedRef(); - const handleClickOutside = useCallback((event: MouseEvent) => { - //console.log('current:', ref.current, 'target:', event.target, 'mouseDownTarget:', mouseRef.current.mouseDownTarget, 'mouseUpTarget:', mouseRef.current.mouseUpTarget); + const [ loadingAbove, setLoadingAbove ] = useState(false); + const [ loadingBelow, setLoadingBelow ] = useState(false); - if (!ref.current) return; + const [ hasMoreAbove, setHasMoreAbove ] = useState(false); + const [ hasMoreBelow, setHasMoreBelow ] = useState(false); - // Casting here is OK. https://stackoverflow.com/q/61164018 - if (ref.current.contains(event.target as Node)) return; + const loadAbove = useCallback(async () => { + if (loadingAbove || !hasMoreAbove) return; + setLoadingAbove(true); + const loadResult = await loadMoreAbove(); + if (!isMounted.current) return; + setHasMoreAbove(loadResult.hasMoreAbove); + setHasMoreBelow(oldHasMoreBelow => oldHasMoreBelow || loadResult.removedFromBottom); + setLoadingAbove(false); + }, [ loadingAbove, hasMoreAbove, loadMoreAbove ]); - if (mouseRef.current.mouseDownTarget !== null || mouseRef.current.mouseUpTarget !== null) return; + const loadBelow = useCallback(async () => { + if (loadingBelow || !hasMoreBelow) return; + setLoadingBelow(true); + const loadResult = await loadMoreBelow(); + if (!isMounted.current) return; + setHasMoreBelow(loadResult.hasMoreBelow); + setHasMoreAbove(oldHasMoreAbove => oldHasMoreAbove || loadResult.removedFromTop); + setLoadingBelow(false); + }, [ loadingBelow, hasMoreBelow, loadMoreBelow ]); - close(); - }, [ ref, mouseRef, close ]); + const onScrollCallable = useCallback(async (event: UIEvent) => { + const scrollTop = event.currentTarget.scrollTop; + const scrollHeight = event.currentTarget.scrollHeight; + const clientHeight = event.currentTarget.clientHeight; - const handleMouseDown = useCallback((event: MouseEvent) => { - if (!ref.current) return; - //console.log('mouse down. Contains: ' + ref.current.contains(event.target as Node)); - if (ref.current.contains(event.target as Node)) { - mouseRef.current.mouseDownTarget = event.target as Node; - } else { - mouseRef.current.mouseDownTarget = null; - } - }, [ ref, mouseRef ]); + // WARNING + // There's likely an inconsistency between browsers on this so have fun when you're working + // on the cross-platform implementation of this + // scrollTop apparantly is negative for column-reverse divs (this actually kindof makes sense if you flip your head upside down) + // have to reverse this + // I expect this was a change with some version of chromium. + // MDN documentation issue: https://github.com/mdn/content/issues/10968 - const handleMouseUp = useCallback((event: MouseEvent) => { - if (!ref.current) return; - //console.log('mouse up. Contains: ' + ref.current.contains(event.target as Node)); - if (ref.current.contains(event.target as Node)) { - mouseRef.current.mouseUpTarget = event.target as Node; - } else { - mouseRef.current.mouseUpTarget = null; - } - }, [ ref, mouseRef ]); + setScrollRatio(Math.abs(scrollTop / scrollHeight)); - const handleContextMenu = useCallback((event: MouseEvent) => { - if (!ref.current) return; - if (ref.current.contains(event.target as Node)) return; - // Context menu is fired on mouse-down so no need to do special checks. - close(); - }, [ ref ]); + const distToTop = -(clientHeight - scrollHeight - scrollTop); // keep in mind scrollTop is negative >:] + const distToBottom = -scrollTop; - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if (!ref.current) return; - if (event.key !== 'Escape') return; + //LOG.debug(`scroll callable update. to top: ${distToTop}, to bottom: ${distToBottom}`) - close(); - }, [ ref ]); + if (distToTop < threshold) { + await loadAbove(); + } - useEffect(() => { - document.addEventListener('click', handleClickOutside); - return () => { - document.removeEventListener('click', handleClickOutside); - } - }, [ handleClickOutside ]); + if (distToBottom < threshold) { + await loadBelow(); + } + }, [ setScrollRatio, loadAbove, loadBelow, threshold ]); - useEffect(() => { - document.addEventListener('mousedown', handleMouseDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - } - }, [ handleMouseDown ]); + const onLoadCallable = useCallback((params: { hasMoreAbove: boolean, hasMoreBelow: boolean }) => { + setHasMoreAbove(params.hasMoreAbove); + setHasMoreBelow(params.hasMoreBelow); + }, []); - useEffect(() => { + return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ]; +} + +// Makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside +export function useCloseWhenEscapeOrClickedOrContextOutsideEffect(ref: RefObject, close: () => void) { + // Have to use a ref here and not states since we can't re-assign state between mouseup and click + const mouseRef = useRef<{ mouseDownTarget: Node | null, mouseUpTarget: Node | null}>({ mouseDownTarget: null, mouseUpTarget: null }); + + const handleClickOutside = useCallback((event: MouseEvent) => { + //console.log('current:', ref.current, 'target:', event.target, 'mouseDownTarget:', mouseRef.current.mouseDownTarget, 'mouseUpTarget:', mouseRef.current.mouseUpTarget); + + if (!ref.current) return; + + // Casting here is OK. https://stackoverflow.com/q/61164018 + if (ref.current.contains(event.target as Node)) return; + + if (mouseRef.current.mouseDownTarget !== null || mouseRef.current.mouseUpTarget !== null) return; + + close(); + }, [ ref, mouseRef, close ]); + + const handleMouseDown = useCallback((event: MouseEvent) => { + if (!ref.current) return; + //console.log('mouse down. Contains: ' + ref.current.contains(event.target as Node)); + if (ref.current.contains(event.target as Node)) { + mouseRef.current.mouseDownTarget = event.target as Node; + } else { + mouseRef.current.mouseDownTarget = null; + } + }, [ ref, mouseRef ]); + + const handleMouseUp = useCallback((event: MouseEvent) => { + if (!ref.current) return; + //console.log('mouse up. Contains: ' + ref.current.contains(event.target as Node)); + if (ref.current.contains(event.target as Node)) { + mouseRef.current.mouseUpTarget = event.target as Node; + } else { + mouseRef.current.mouseUpTarget = null; + } + }, [ ref, mouseRef ]); + + const handleContextMenu = useCallback((event: MouseEvent) => { + if (!ref.current) return; + if (ref.current.contains(event.target as Node)) return; + // Context menu is fired on mouse-down so no need to do special checks. + close(); + }, [ ref ]); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (!ref.current) return; + if (event.key !== 'Escape') return; + + close(); + }, [ ref ]); + + useEffect(() => { + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + } + }, [ handleClickOutside ]); + + useEffect(() => { + document.addEventListener('mousedown', handleMouseDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + } + }, [ handleMouseDown ]); + + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + return () => { document.addEventListener('mouseup', handleMouseUp); - return () => { - document.addEventListener('mouseup', handleMouseUp); - } - }, [ handleMouseUp ]); + } + }, [ handleMouseUp ]); - useEffect(() => { - document.addEventListener('contextmenu', handleContextMenu); - return () => { - document.removeEventListener('contextmenu', handleContextMenu); - } - }, [ handleClickOutside ]); + useEffect(() => { + document.addEventListener('contextmenu', handleContextMenu); + return () => { + document.removeEventListener('contextmenu', handleContextMenu); + } + }, [ handleClickOutside ]); - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - } - }, [ handleKeyDown ]); - } + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + } + }, [ handleKeyDown ]); +} - static useAlignment( - rootRef: RefObject, - relativeToRef: RefObject | null, - relativeToPos: { x: number, y: number } | null, - alignment: IAlignment, - baseClassName: string - ): [ - className: string - ] { - const [ aligned, setAligned ] = useState(false); +export function useAlignment( + rootRef: RefObject, + relativeToRef: RefObject | null, + relativeToPos: { x: number, y: number } | null, + alignment: IAlignment, + baseClassName: string +): [ + className: string +] { + const [ aligned, setAligned ] = useState(false); - useEffect(() => { - if (!rootRef.current) return; - const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null; - if (!relativeTo) throw new ShouldNeverHappenError('invalid alignment props'); - ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment); - setAligned(true); - }, [ rootRef, relativeToRef, relativeToPos ]); + useEffect(() => { + if (!rootRef.current) return; + const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null; + if (!relativeTo) throw new ShouldNeverHappenError('invalid alignment props'); + ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment); + setAligned(true); + }, [ rootRef, relativeToRef, relativeToPos ]); - const className = useMemo(() => { - return baseClassName + (aligned ? ' aligned' : ''); - }, [ baseClassName, aligned ]); + const className = useMemo(() => { + return baseClassName + (aligned ? ' aligned' : ''); + }, [ baseClassName, aligned ]); - return [ className ]; - } + return [ className ]; +} - static useContextMenu( - createContextMenu: (close: () => void) => ReactNode, - createContextMenuDeps: DependencyList - ): [ - contextMenu: ReactNode, - toggle: () => void, - close: () => void, - open: () => void, - isOpen: boolean - ] { - const [ isOpen, setIsOpen ] = useState(false); +export function useContextMenu( + createContextMenu: (close: () => void) => ReactNode, + createContextMenuDeps: DependencyList +): [ + contextMenu: ReactNode, + toggle: () => void, + close: () => void, + open: () => void, + isOpen: boolean +] { + const [ isOpen, setIsOpen ] = useState(false); - const close = useCallback(() => { - setIsOpen(false); - }, []); - const contextMenu = useMemo(() => { - return createContextMenu(close); - }, [ close, createContextMenu, ...createContextMenuDeps ]); + const close = useCallback(() => { + setIsOpen(false); + }, []); + const contextMenu = useMemo(() => { + return createContextMenu(close); + }, [ close, createContextMenu, ...createContextMenuDeps ]); - const toggle = useCallback(() => { - setIsOpen(oldIsOpen => !!contextMenu && !oldIsOpen); - }, [ contextMenu ]); - const open = useCallback(() => { - setIsOpen(true); - }, [ contextMenu ]); + const toggle = useCallback(() => { + setIsOpen(oldIsOpen => !!contextMenu && !oldIsOpen); + }, [ contextMenu ]); + const open = useCallback(() => { + setIsOpen(true); + }, [ contextMenu ]); - return [ isOpen ? contextMenu : null, toggle, close, open, isOpen ]; - } - - static useContextHover( - createContextHover: () => ReactNode, - createContextHoverDeps: DependencyList - ): [ - contextHover: ReactNode, - mouseEnterCallable: () => void, - mouseLeaveCallable: () => void - ] { - const [ isOpen, setIsOpen ] = useState(false); - - const contextHover = useMemo(() => { - return createContextHover(); - }, [ createContextHover, ...createContextHoverDeps ]); - - const mouseEnterCallable = useCallback(() => { - setIsOpen(true); - }, []); - const mouseLeaveCallable = useCallback(() => { - setIsOpen(false); - }, []); - - return [ isOpen ? contextHover : null, mouseEnterCallable, mouseLeaveCallable ]; - } - - static useDocumentDropTarget( - message: string, - setBuff: Dispatch>, - setName: Dispatch> - ): [ dropTarget: ReactNode ] { - const [ dragEnabled, setDragEnabled ] = useState(false); - - // Drag overlay - const closeDragOverlay = useCallback(() => { setDragEnabled(false); }, []); - - const onDragEnter = useCallback((event: DragEvent) => { - event.preventDefault(); - setDragEnabled(true); - }, []); - - useEffect(() => { - document.addEventListener('dragenter', onDragEnter); - - return () => { - document.removeEventListener('dragenter', onDragEnter); - } - }, [ closeDragOverlay, onDragEnter ]); - - const dropTarget = useMemo(() => { - if (!dragEnabled) return null; - return ( - - ); - }, [ dragEnabled, message, setBuff, setName ]); - - return [ dropTarget ]; - } + return [ isOpen ? contextMenu : null, toggle, close, open, isOpen ]; } export function useContextClickContextMenu( @@ -560,7 +485,7 @@ export function useContextClickContextMenu( return createContextMenu(alignment, relativeToPos, close); }, [ alignment, relativeToPos ]); - const [ contextMenu, toggle, close, open ] = ReactHelper.useContextMenu(createContextMenuWithRelativeToPos, createContextMenuDeps); + const [ contextMenu, toggle, close, open ] = useContextMenu(createContextMenuWithRelativeToPos, createContextMenuDeps); const onContextMenu = useCallback((event: React.MouseEvent) => { setRelativeToPos({ x: event.clientX, y: event.clientY }); @@ -569,3 +494,63 @@ export function useContextClickContextMenu( return [ contextMenu, onContextMenu ]; } + +export function useContextHover( + createContextHover: () => ReactNode, + createContextHoverDeps: DependencyList +): [ + contextHover: ReactNode, + mouseEnterCallable: () => void, + mouseLeaveCallable: () => void +] { + const [ isOpen, setIsOpen ] = useState(false); + + const contextHover = useMemo(() => { + return createContextHover(); + }, [ createContextHover, ...createContextHoverDeps ]); + + const mouseEnterCallable = useCallback(() => { + setIsOpen(true); + }, []); + const mouseLeaveCallable = useCallback(() => { + setIsOpen(false); + }, []); + + return [ isOpen ? contextHover : null, mouseEnterCallable, mouseLeaveCallable ]; +} + +export function useDocumentDropTarget( + message: string, + setBuff: Dispatch>, + setName: Dispatch> +): [ dropTarget: ReactNode ] { + const [ dragEnabled, setDragEnabled ] = useState(false); + + // Drag overlay + const closeDragOverlay = useCallback(() => { setDragEnabled(false); }, []); + + const onDragEnter = useCallback((event: DragEvent) => { + event.preventDefault(); + setDragEnabled(true); + }, []); + + useEffect(() => { + document.addEventListener('dragenter', onDragEnter); + + return () => { + document.removeEventListener('dragenter', onDragEnter); + } + }, [ closeDragOverlay, onDragEnter ]); + + const dropTarget = useMemo(() => { + if (!dragEnabled) return null; + return ( + + ); + }, [ dragEnabled, message, setBuff, setName ]); + + return [ dropTarget ]; +} diff --git a/src/client/webapp/elements/sections/connection-info.tsx b/src/client/webapp/elements/sections/connection-info.tsx index 611e6cd..ed51140 100644 --- a/src/client/webapp/elements/sections/connection-info.tsx +++ b/src/client/webapp/elements/sections/connection-info.tsx @@ -8,7 +8,7 @@ import { Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import MemberElement, { DummyMember } from '../lists/components/member-element'; import ConnectionInfoContextMenu from '../contexts/context-menu-connection-info'; -import ReactHelper from '../require/react-helper'; +import { useContextMenu } from '../require/react-helper'; export interface ConnectionInfoProps { guild: CombinedGuild; @@ -34,7 +34,7 @@ const ConnectionInfo: FC = (props: ConnectionInfoProps) => return selfMember; }, [ selfMember ]); - const [ contextMenu, toggleContextMenu ] = ReactHelper.useContextMenu((close: () => void) => { + const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => { if (!selfMember) return null; return ( = (props: GuildListContain const addGuildRef = useRef(null); - const [ contextHover, onMouseEnter, onMouseLeave ] = ReactHelper.useContextHover(() => { + const [ contextHover, onMouseEnter, onMouseLeave ] = useContextHover(() => { return (
@@ -38,7 +38,7 @@ const GuildListContainer: FC = (props: GuildListContain ); }, []); - const [ onAddGuildClickCallback ] = ReactHelper.useAsyncVoidCallback(async () => { + const [ onAddGuildClickCallback ] = useAsyncVoidCallback(async () => { // TODO: Change this to a file input // We'll probably have to do this eventually for PWA. const result = await electronRemote.dialog.showOpenDialog({ diff --git a/src/client/webapp/elements/sections/guild-title.tsx b/src/client/webapp/elements/sections/guild-title.tsx index cb24f1f..c7221d5 100644 --- a/src/client/webapp/elements/sections/guild-title.tsx +++ b/src/client/webapp/elements/sections/guild-title.tsx @@ -2,7 +2,7 @@ import React, { Dispatch, FC, ReactNode, SetStateAction, useMemo, useRef } from import { GuildMetadata, Member } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import GuildTitleContextMenu from '../contexts/context-menu-guild-title'; -import ReactHelper from '../require/react-helper'; +import { useContextMenu } from '../require/react-helper'; export interface GuildTitleProps { guild: CombinedGuild; @@ -26,7 +26,7 @@ const GuildTitle: FC = (props: GuildTitleProps) => { ); }, [ selfMember ]); - const [ contextMenu, toggleContextMenu ] = ReactHelper.useContextMenu((close: () => void) => { + const [ contextMenu, toggleContextMenu ] = useContextMenu((close: () => void) => { if (!guildMeta) return null; if (!selfMember) return null; return ( diff --git a/src/client/webapp/elements/sections/send-message.tsx b/src/client/webapp/elements/sections/send-message.tsx index 650caf4..d7b801b 100644 --- a/src/client/webapp/elements/sections/send-message.tsx +++ b/src/client/webapp/elements/sections/send-message.tsx @@ -7,7 +7,7 @@ import React, { ClipboardEvent, FC, FormEvent, KeyboardEvent, RefObject, useCall import { Channel } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import BaseElements from '../require/base-elements'; -import ReactHelper from '../require/react-helper'; +import { useAsyncVoidCallback, useDocumentDropTarget, useIsMountedRef, useOneTimeAsyncAction } from '../require/react-helper'; import * as FileType from 'file-type'; import ElementsUtil from '../require/elements-util'; import FileInput from '../components/input-file'; @@ -21,7 +21,7 @@ export interface AttachmentPreviewProps { const AttachmentPreview: FC = (props: AttachmentPreviewProps) => { const { attachmentBuff, attachmentName, remove } = props; - const [ attachmentImgSrc ] = ReactHelper.useOneTimeAsyncAction( + const [ attachmentImgSrc ] = useOneTimeAsyncAction( async () => { const type = await FileType.fromBuffer(attachmentBuff); if (!type) return './img/file-improved.svg'; @@ -54,7 +54,7 @@ export interface SendMessageProps { const SendMessage: FC = (props: SendMessageProps) => { const { guild, channel } = props; - const isMounted = ReactHelper.useIsMountedRef(); + const isMounted = useIsMountedRef(); const contentEditableRef = useRef(null); const [ text, setText ] = useState(''); @@ -63,7 +63,7 @@ const SendMessage: FC = (props: SendMessageProps) => { const [ attachmentBuff, setAttachmentBuff ] = useState(null); const [ attachmentName, setAttachmentName ] = useState(null); - const [ sendCallable ] = ReactHelper.useAsyncVoidCallback( + const [ sendCallable ] = useAsyncVoidCallback( async (isMounted: RefObject) => { if (!enabled) return; setEnabled(false); @@ -120,7 +120,7 @@ const SendMessage: FC = (props: SendMessageProps) => { setAttachmentName(null); }, []); - const [ attachmentDropTarget ] = ReactHelper.useDocumentDropTarget('Upload to #' + channel.name, setAttachmentBuff, setAttachmentName); + const [ attachmentDropTarget ] = useDocumentDropTarget('Upload to #' + channel.name, setAttachmentBuff, setAttachmentName); const attachmentPreview = useMemo(() => { if (!attachmentBuff || !attachmentName) return null;