From 82546300ccfcac2d7786855a6a35d5993d3f6bf7 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Mon, 6 Dec 2021 23:06:59 -0600 Subject: [PATCH] react-based image popup --- .../elements/components/button-download.tsx | 109 ++++++++++++++++++ .../webapp/elements/components/button.tsx | 14 +++ .../webapp/elements/components/overlay.tsx | 64 ++++++++++ src/client/webapp/elements/msg-img-res.tsx | 9 +- .../elements/overlays/overlay-image.tsx | 63 ++++++++++ .../webapp/elements/require/base-elements.tsx | 8 +- .../webapp/elements/require/elements-util.tsx | 9 ++ src/client/webapp/index.html | 2 + src/client/webapp/styles/overlays.scss | 3 +- src/client/webapp/util.ts | 6 + 10 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 src/client/webapp/elements/components/button-download.tsx create mode 100644 src/client/webapp/elements/components/button.tsx create mode 100644 src/client/webapp/elements/components/overlay.tsx create mode 100644 src/client/webapp/elements/overlays/overlay-image.tsx diff --git a/src/client/webapp/elements/components/button-download.tsx b/src/client/webapp/elements/components/button-download.tsx new file mode 100644 index 0000000..d53daaa --- /dev/null +++ b/src/client/webapp/elements/components/button-download.tsx @@ -0,0 +1,109 @@ +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 electron from 'electron'; +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { Resource, ShouldNeverHappenError } from '../../data-types'; +import CombinedGuild from '../../guild-combined'; +import Util from '../../util'; +import Globals from '../../globals'; +import path from 'path'; +import * as fs from 'fs/promises'; +import Button from './button'; +import ElementsUtil from '../require/elements-util'; + +interface DownloadButtonProps { + /* Fetch based on resource id */ + guild?: CombinedGuild, + resourceId?: string, + /* Pre-define the download buffer */ + downloadBuff?: Buffer, + downloadBuffErr?: boolean, + + resourceName: string +} + +type ButtonText = 'Loading...' | 'Error' | 'Save' | 'Downloading...' | 'Writing...' | 'Reveal in Explorer' | 'Try Again'; + +const DownloadButton: FC = (props: DownloadButtonProps) => { + const { guild, resourceId, downloadBuffErr, downloadBuff, resourceName } = props; + + const [ downloading, setDownloading ] = useState(false); + const [ downloadPath, setDownloadPath ] = useState(null); + const [ buttonText, setButtonText ] = useState('Save'); + const [ buttonShaking, setButtonShaking ] = useState(false); + + useEffect(() => { + if (downloading === false && downloadPath === null) { + if (guild && resourceId) { + setButtonText('Save'); + } else { + if (downloadBuff) { + setButtonText('Save'); + } else if (downloadBuffErr) { + setButtonText('Error'); + } else { + setButtonText('Loading...'); + } + } + } + }, [ downloading, downloadPath, guild, resourceId, downloadBuff, downloadBuffErr, buttonText ]) + + useEffect(() => { + if (downloading === false && downloadPath === null && ((guild && resourceId) || (downloadBuff))) { + setButtonText('Save'); + } + }, [ downloading, downloadPath, guild, resourceId, downloadBuff ]); + + const downloadListener = useCallback(async () => { + if (downloading || buttonShaking) return; + if (downloadPath && await Util.exists(downloadPath)) { + electron.shell.showItemInFolder(downloadPath); + return; + } + + setDownloading(true); + setButtonText('Downloading...'); + + let resourceBuff: Buffer; + if (downloadBuff) { + resourceBuff = downloadBuff; + } else { + try { + if (!guild) throw new ShouldNeverHappenError('guild is null and we are not using a pre-download'); + if (!resourceId) throw new ShouldNeverHappenError('resourceId is null and we are not using a pre-download'); + resourceBuff = (await guild.fetchResource(resourceId)).data; + } catch (e: unknown) { + LOG.error('Error downloading resource', { e }); + setDownloading(false); + setDownloadPath(null); + setButtonText('Try Again'); + await ElementsUtil.delayToggleState(setButtonShaking, 400); + return; + } + } + + try { + const availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, resourceName); + const availablePath = path.join(Globals.DOWNLOAD_DIR, availableName); + await fs.writeFile(availablePath, resourceBuff); + setDownloadPath(availablePath); + } catch (e: unknown) { + LOG.error('Error writing download file', e); + setDownloading(false); + setDownloadPath(null); + setButtonText('Try Again'); + await ElementsUtil.delayToggleState(setButtonShaking, 400); + return; + } + + setButtonText('Reveal in Explorer'); + setDownloading(false); + }, [ downloading, buttonShaking, downloadPath, downloadBuff ]); + + return +} + +export default DownloadButton; \ No newline at end of file diff --git a/src/client/webapp/elements/components/button.tsx b/src/client/webapp/elements/components/button.tsx new file mode 100644 index 0000000..53a9eab --- /dev/null +++ b/src/client/webapp/elements/components/button.tsx @@ -0,0 +1,14 @@ +import React, { FC } from 'react'; + +interface ButtonProps { + onClick: () => void + shaking?: boolean; + children: React.ReactNode +} + +const Button: FC = (props: ButtonProps) => { + const { onClick, shaking, children } = props; + return
{children}
+} + +export default Button; \ No newline at end of file diff --git a/src/client/webapp/elements/components/overlay.tsx b/src/client/webapp/elements/components/overlay.tsx new file mode 100644 index 0000000..e0dcbec --- /dev/null +++ b/src/client/webapp/elements/components/overlay.tsx @@ -0,0 +1,64 @@ +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, { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactDOM from "react-dom"; + +interface OverlayProps { + children: React.ReactNode; +} +const Overlay: FC = (props: OverlayProps) => { + const { children } = props; + + const node = useRef(null); + + const [ mouseDownInChild, setMouseDownInChild ] = useState(false); + const [ mouseUpInChild, setMouseUpInChild ] = useState(false); + + const removeSelf = () => { + if (mouseDownInChild || mouseUpInChild) { + setMouseDownInChild(false); + setMouseUpInChild(false); + return; + } + if (node.current) { + ReactDOM.unmountComponentAtNode(node.current.parentElement as Element); + } + if (keyDownEventHandler) { + window.removeEventListener('keydown', keyDownEventHandler); + } + // otherwise, this isn't in the DOM anyway + }; + + const keyDownEventHandler = useMemo(() => { + const eventHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + removeSelf(); + } + }; + window.addEventListener('keydown', eventHandler); + return eventHandler; + }, []); + + const checkMouseDown = (e: React.MouseEvent) => { + if (e.target === node.current) { + setMouseDownInChild(false); + } else { + setMouseDownInChild(true); + } + }; + + const checkMouseUp = (e: React.MouseEvent) => { + if (e.target === node.current) { + setMouseUpInChild(false); + } else { + setMouseUpInChild(true); + } + }; + + return
{children}
+}; + +export default Overlay; diff --git a/src/client/webapp/elements/msg-img-res.tsx b/src/client/webapp/elements/msg-img-res.tsx index b28bdc8..432ef6b 100644 --- a/src/client/webapp/elements/msg-img-res.tsx +++ b/src/client/webapp/elements/msg-img-res.tsx @@ -10,12 +10,13 @@ import ElementsUtil from './require/elements-util.js'; import { Message, Member, ShouldNeverHappenError } from '../data-types'; import Q from '../q-module'; -import createImageOverlay from './overlay-image'; import createImageContextMenu from './context-menu-img'; import CombinedGuild from '../guild-combined'; import React from 'react'; import ReactHelper from './require/react-helper'; +import BaseElements from './require/base-elements'; +import ImageOverlay from './overlays/overlay-image'; export default function createImageResourceMessage(document: Document, q: Q, guild: CombinedGuild, message: Message): Element { if (!message.resourceId || !message.resourcePreviewId || !message.resourceName) { @@ -61,7 +62,11 @@ export default function createImageResourceMessage(document: Document, q: Q, gui ); q.$$$(element, '.content.image').addEventListener('click', (e) => { - document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string)); + BaseElements.presentReactOverlay(document, + + ); + //document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string)); }); (async () => { (q.$$$(element, '.member-avatar img') as HTMLImageElement).src = diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx new file mode 100644 index 0000000..29ed243 --- /dev/null +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -0,0 +1,63 @@ +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, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import CombinedGuild from '../../guild-combined'; +import ElementsUtil from '../require/elements-util'; +import { Resource } from '../../data-types'; +import Button from '../components/button'; +import DownloadButton from '../components/button-download'; + +type ButtonText = 'Loading...' | 'Error' | 'Save' | 'Downloading...' | 'Writing...' | 'Reveal in Explorer' | 'Try Again'; + +export interface ImageOverlayProps { + guild: CombinedGuild + resourceId: string, + resourceName: string +} + +const ImageOverlay: FC = (props: ImageOverlayProps) => { + const { guild, resourceId, resourceName } = props; + + const [ resource, setResource ] = useState(null); + const [ resourceImgSrc, setResourceImgSrc ] = useState('./img/loading.svg'); + const [ resourceErr, setResourceErr ] = useState(false); + + useEffect(() => { + (async () => { + try { + const resource = await guild.fetchResource(resourceId); + setResource(resource); + const resourceImgSrc = await ElementsUtil.getImageBufferSrc(resource.data); + setResourceImgSrc(resourceImgSrc); + } catch (e) { + LOG.error('unable to load image for overlay', e); + setResource(null); + setResourceImgSrc('./img/error.png'); + setResourceErr(true); + } + + })(); + }); + + const sizeText = useMemo(() => resource ? ElementsUtil.humanSize(resource.data.length) : 'Loading Size...', [ resource ]); + + return ( +
{ e.stopPropagation(); /* prevent overlay click */ }}> + {resourceName} +
+
+
{resourceName}
+
{sizeText}
+
+ +
+
+ ); +} + +export default ImageOverlay; diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index 9cafca9..a499161 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -14,6 +14,8 @@ import { Channel } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import Q from '../../q-module'; import ReactHelper from './react-helper'; +import Overlay from '../components/overlay'; +import ReactDOM from 'react-dom'; export interface HTMLElementWithRemoveSelf extends HTMLElement { removeSelf: (() => void); @@ -249,12 +251,16 @@ export default class BaseElements { return element as HTMLElementWithRemoveSelf; } + static presentReactOverlay(document: Document, content: JSX.Element) { + const overlay = {content}; + ReactDOM.render(overlay, document.querySelector('#react-overlays')); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any static createOverlay(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf { const q = new Q(document); let wasDownInternal = false; // because 'click' fires on the overlay element anyway - // eslint-disable-next-line @typescript-eslint/no-explicit-any const element: HTMLElementWithRemoveSelf = ReactHelper.createElementFromJSX(
{content}
) as HTMLElementWithRemoveSelf; element.removeSelf = () => { if (element.parentElement) { diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index f8b3c9c..eb9b613 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -87,6 +87,15 @@ export default class ElementsUtil { element.classList.remove('shaking-horizontal'); } + // Calls a function with the start parameter and then the inverse of the start parameter after a determined number of ms + // There is no way to cancel this function + // Useful for enabling a "shake" element for a pre-determined amount of time + static async delayToggleState(setState: React.Dispatch>, delayMs: number, start = true): Promise { + setState(start); + await Util.sleep(delayMs); + setState(old => !start); + } + static async getImageBufferSrc(buffer: Buffer): Promise { const result = await FileType.fromBuffer(buffer); switch (result && result.mime) { diff --git a/src/client/webapp/index.html b/src/client/webapp/index.html index 39afd4a..2fb670e 100644 --- a/src/client/webapp/index.html +++ b/src/client/webapp/index.html @@ -92,5 +92,7 @@ + +
diff --git a/src/client/webapp/styles/overlays.scss b/src/client/webapp/styles/overlays.scss index f655862..33cfa6e 100644 --- a/src/client/webapp/styles/overlays.scss +++ b/src/client/webapp/styles/overlays.scss @@ -2,7 +2,8 @@ /* Popup Image Overlay */ -body > .overlay { +body > .overlay, +#react-overlays > .overlay { /* Note: skip top 22px so we don't overlay on the title bar */ position: absolute; width: 100vw; diff --git a/src/client/webapp/util.ts b/src/client/webapp/util.ts index 0cb80f7..a9a6a54 100644 --- a/src/client/webapp/util.ts +++ b/src/client/webapp/util.ts @@ -127,6 +127,12 @@ export default class Util { } } + static async sleep(ms: number): Promise { + return await new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); + } + // This function is a promise for error stack tracking purposes. // this function expects the last argument of args to be a callback(err, serverData) static socketEmitTimeout(