diff --git a/src/client/webapp/elements/context-menu-guild.tsx b/src/client/webapp/elements/context-menu-guild.tsx deleted file mode 100644 index d87845c..0000000 --- a/src/client/webapp/elements/context-menu-guild.tsx +++ /dev/null @@ -1,36 +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 BaseElements from './require/base-elements.js'; - -import Q from '../q-module'; -import UI from '../ui'; -import GuildsManager from '../guilds-manager'; -import CombinedGuild from '../guild-combined'; - -import React from 'react'; - -export default function createGuildContextMenu(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild) { - const element = BaseElements.createContextMenu(document, ( -
-
Leave Guild
-
- )); - - q.$$$(element, '.leave-guild').addEventListener('click', async () => { - element.removeSelf(); - guild.disconnect(); - await guildsManager.removeGuild(guild); - await ui.removeGuild(guild); - const firstGuildElement = q.$_('#guild-list .guild'); - if (firstGuildElement) { - firstGuildElement.click(); - } else { - LOG.warn('no first guild element to click on'); - } - }); - - return element; -} diff --git a/src/client/webapp/elements/error-indicator.tsx b/src/client/webapp/elements/error-indicator.tsx deleted file mode 100644 index 1c257ac..0000000 --- a/src/client/webapp/elements/error-indicator.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as electronRemote from '@electron/remote'; -const electronConsole = electronRemote.getGlobal('console') as Console; -import Logger from '../../../logger/logger'; -import Q from '../q-module'; -const LOG = Logger.create(__filename, electronConsole); - -import ElementsUtil from './require/elements-util'; -import React from 'react'; -import ReactHelper from './require/react-helper'; - -export interface CreateErrorIndicatorProps { - container: HTMLElement; - classes?: string[]; - message: string; - taskFunc: (() => Promise); - resolveFunc: ((result: unknown) => void); - rejectFunc: ((err: Error) => void); -} - -// resolveFunc and rejectFunc should be the resolve/reject functions from the withPotentialError promise -export default function createErrorIndicator(q: Q, props: CreateErrorIndicatorProps): Element { - props.classes = props.classes ?? []; - const { container, classes, message, taskFunc, resolveFunc, rejectFunc } = props; - - const element = ReactHelper.createElementFromJSX( -
- error -
-
{message}
-
Try Again
-
-
- ); - - const observer = new MutationObserver(() => { - if (element.parentElement == null) { - rejectFunc(new Error('indicator removed')); - observer.disconnect(); - } - }); - observer.observe(container, { childList: true }); - - let retrying = false; - q.$$$(element, '.retry-button').addEventListener('click', async () => { - if (retrying) return; - - retrying = true; - q.$$$(element, '.retry-button').innerText = 'Fetching...'; - try { - observer.disconnect(); - await taskFunc(); - resolveFunc(null); - } catch (e) { - observer.observe(container, { childList: true }); - LOG.error('error during retry', e); - q.$$$(element, '.retry-button').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.retry-button'), 400); - } - retrying = false; - }); - - return element; -} diff --git a/src/client/webapp/elements/events-add-guild.tsx b/src/client/webapp/elements/events-add-guild.tsx deleted file mode 100644 index d933514..0000000 --- a/src/client/webapp/elements/events-add-guild.tsx +++ /dev/null @@ -1,86 +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 * as fs from 'fs/promises'; - -import ElementsUtil from './require/elements-util'; - -import Q from '../q-module'; -import UI from '../ui'; -import GuildsManager from '../guilds-manager'; -import AddGuildOverlay from './overlays/overlay-add-guild'; -import React from 'react'; -import ErrorMessageOverlay from './overlays/overlay-error-message'; - -export default function bindAddGuildEvents(document: Document, q: Q, ui: UI, guildsManager: GuildsManager): void { - let choosingFile = false; - q.$('#add-guild').addEventListener('click', async () => { - if (choosingFile) return; - choosingFile = true; - - const result = await electronRemote.dialog.showOpenDialog({ - title: 'Select Guild File', - defaultPath: '.', // TODO: better path name - properties: [ 'openFile' ], - filters: [ - { name: 'Cordis Guild Files', extensions: [ 'cordis' ] } - ] - }); - - if (result.canceled || result.filePaths.length === 0) { - choosingFile = false; - return; - } - - const filePath = result.filePaths[0] as string; - const fileText = (await fs.readFile(filePath)).toString('utf-8'); // TODO: try/catch? - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let addGuildData: any | null = null; - try { - addGuildData = JSON.parse(fileText); - if ( - typeof addGuildData !== 'object' || - typeof addGuildData?.name !== 'string' || - typeof addGuildData?.url !== 'string' || - typeof addGuildData?.cert !== 'string' || - typeof addGuildData?.token !== 'string' || - typeof addGuildData?.expires !== 'number' || - typeof addGuildData?.iconSrc !== 'string' - ) { - LOG.debug('bad guild data:', { addGuildData, fileText }) - throw new Error('bad guild data'); - } - ElementsUtil.presentReactOverlay(document, ); - } catch (e: unknown) { - LOG.error('Unable to parse guild data', e); - ElementsUtil.presentReactOverlay(document, ); - } - - choosingFile = false; - }); - - const contextElement = q.create({ class: 'context', content: { - class: 'info', content: [ - { ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 10, height: 20, viewBox: '0 0 8 12', content: [ - { ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', //'fill-rule': 'evenodd', 'clip-rule': 'evenodd', - d: 'M 0,6 ' + - 'L 8,12 ' + - 'L 8,0 ' + - 'Z' } - ] }, - { class: 'content', content: 'Add a Guild' } - ] - } }) as HTMLElement; - q.$('#add-guild').addEventListener('mouseenter', () => { - document.body.appendChild(contextElement); - ElementsUtil.alignContextElement(contextElement, q.$('#add-guild'), { left: 'right', centerY: 'centerY' }) - }); - q.$('#add-guild').addEventListener('mouseleave', () => { - if (contextElement.parentElement) { - contextElement.parentElement.removeChild(contextElement); - } - }); -} diff --git a/src/client/webapp/elements/guild-list-guild.tsx b/src/client/webapp/elements/guild-list-guild.tsx deleted file mode 100644 index 0d8d10d..0000000 --- a/src/client/webapp/elements/guild-list-guild.tsx +++ /dev/null @@ -1,90 +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 BaseElements from './require/base-elements'; -import ElementsUtil from './require/elements-util'; - -import { GuildMetadata } from '../data-types'; -import Q from '../q-module'; -import UI from '../ui'; -import createGuildContextMenu from './context-menu-guild'; -import GuildsManager from '../guilds-manager'; -import CombinedGuild from '../guild-combined'; - -import React from 'react'; -import ReactHelper from './require/react-helper'; - -export default function createGuildListGuild(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild): Element { - const element = ReactHelper.createElementFromJSX( -
-
- guild -
- ) as HTMLElement; - - // Hover over for name + connection info - (async () => { - let guildData: GuildMetadata; - try { - guildData = await guild.fetchMetadata(); - if (!guildData.iconResourceId) throw new Error('guild icon not identified yet'); - const guildIcon = await guild.fetchResource(guildData.iconResourceId); - const guildIconSrc = await ElementsUtil.getImageBufferSrc(guildIcon.data); - (q.$$$(element, 'img') as HTMLImageElement).src = guildIconSrc; - } catch (e) { - LOG.error('Error fetching guild icon', e); - (q.$$$(element, 'img') as HTMLImageElement).src = './img/error.png'; - return; - } - - element.setAttribute('meta-name', guildData.name); - - const contextElement = q.create({ class: 'context', content: { - class: 'info', content: [ - BaseElements.Q_TAB_LEFT, - { class: 'content guild' } // populated later - ] - } }) as HTMLElement; - - // TODO: future: update the status in real-time with the update-member event - - element.addEventListener('mouseenter', async () => { - Q.clearChildren(q.$$$(contextElement, '.content')); - q.$$$(contextElement, '.content').appendChild(q.create({ class: 'name', content: element.getAttribute('meta-name') })); - document.body.appendChild(contextElement); - ElementsUtil.alignContextElement(contextElement, element, { left: 'right', centerY: 'centerY' }); - (async () => { - const connection = await guild.fetchConnectionInfo(); - const connectionElement = q.create({ class: 'connection ' + connection.status, content: [ - { class: 'status-circle' }, - { class: 'display-name', content: connection.displayName } - ] }); - q.$$$(contextElement, '.content').appendChild(connectionElement); - ElementsUtil.alignContextElement(contextElement, element, { left: 'right', centerY: 'centerY' }); - })(); - }); - - element.addEventListener('mouseleave', () => { - if (contextElement.parentElement) { - contextElement.parentElement.removeChild(contextElement); - } - }); - })(); - - element.addEventListener('click', async () => { - if (element.classList.contains('active')) return; - - ui.setActiveGuild(guild); - }); - - element.addEventListener('contextmenu', (e) => { - const contextMenu = createGuildContextMenu(document, q, ui, guildsManager, guild); - document.body.appendChild(contextMenu); - const relativeTo = { x: e.pageX, y: e.pageY }; - ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'centerX' }); - }); - - return element; -} 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 79cb5ed..8204787 100644 --- a/src/client/webapp/elements/lists/components/guild-list-element.tsx +++ b/src/client/webapp/elements/lists/components/guild-list-element.tsx @@ -69,7 +69,6 @@ const GuildListElement: FC = (props: GuildListElementProp }, [ openMenu ]); const className = useMemo(() => { - console.log('active: ' + activeGuild?.id + '/ me: ' + guild.id); return activeGuild && guild.id === activeGuild.id ? 'guild active' : 'guild'; }, [ guild, activeGuild ]); diff --git a/src/client/webapp/elements/lists/guild-list.tsx b/src/client/webapp/elements/lists/guild-list.tsx index e62a465..70ce47d 100644 --- a/src/client/webapp/elements/lists/guild-list.tsx +++ b/src/client/webapp/elements/lists/guild-list.tsx @@ -1,35 +1,27 @@ -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { Dispatch, FC, SetStateAction, useMemo } from 'react'; import CombinedGuild from '../../guild-combined'; import GuildsManager from '../../guilds-manager'; -import UI from '../../ui'; -import Util from '../../util'; -import { useGuildListSubscription } from '../require/guild-manager-subscriptions'; import GuildListElement from './components/guild-list-element'; export interface GuildListProps { guildsManager: GuildsManager; - ui: UI; + guilds: CombinedGuild[]; + activeGuild: CombinedGuild | null; + setActiveGuild: Dispatch>; } const GuildList: FC = (props: GuildListProps) => { - const { guildsManager, ui } = props; - - const [ guilds ] = useGuildListSubscription(guildsManager); - const [ activeGuild, setActiveGuild ] = useState(null); - - // TODO: Remove dependency on UI - useEffect(() => { - if (activeGuild !== null) { - (async () => { - await Util.sleep(0); - ui.setActiveGuild(activeGuild); - })(); - } - }, [ activeGuild ]); + const { guildsManager, guilds, activeGuild, setActiveGuild } = props; const guildElements = useMemo(() => { - return guilds.map((guild: CombinedGuild) => { setActiveGuild(guild); } } />); - }, [ guilds ]); + return guilds.map((guild: CombinedGuild) => ( + { setActiveGuild(guild); } } + /> + )); + }, [ guildsManager, guilds, activeGuild ]); return (
diff --git a/src/client/webapp/elements/mounts.tsx b/src/client/webapp/elements/mounts.tsx deleted file mode 100644 index 7ca7e80..0000000 --- a/src/client/webapp/elements/mounts.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import CombinedGuild from "../guild-combined"; -import GuildsManager from '../guilds-manager'; -import Q from "../q-module"; -import UI from '../ui'; -import GuildList from './lists/guild-list'; -import ElementsUtil from "./require/elements-util"; -import GuildElement from './sections/guild'; - -export function mountBaseComponents(q: Q, ui: UI, guildsManager: GuildsManager) { - // guild-list - // TODO - console.log(q.$('.guild-list-anchor')); - ElementsUtil.unmountReactComponent(q.$('.guild-list-anchor')); - ElementsUtil.mountReactComponent(q.$('.guild-list-anchor'), ); -} - -export function mountGuildComponents(q: Q, guild: CombinedGuild) { - console.log(q.$('.guild-anchor')); - ElementsUtil.unmountReactComponent(q.$('.guild-anchor')); - ElementsUtil.mountReactComponent(q.$('.guild-anchor'), ); -} diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index c68c393..1b57f2d 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -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, useEffect, useMemo, useRef, useState } from 'react'; +import React, { Dispatch, FC, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'; import GuildsManager from '../../guilds-manager'; import moment from 'moment'; import TextInput from '../components/input-text'; @@ -11,7 +11,6 @@ import ImageEditInput from '../components/input-image-edit'; import Globals from '../../globals'; import SubmitOverlayLower from '../components/submit-overlay-lower'; import path from 'path'; -import UI from '../../ui'; import CombinedGuild from '../../guild-combined'; import ElementsUtil from '../require/elements-util'; import InvitePreview from '../components/invite-preview'; @@ -52,14 +51,13 @@ function getExampleAvatarPath(): string { } export interface AddGuildOverlayProps { - document: Document; - ui: UI; guildsManager: GuildsManager; addGuildData: IAddGuildData; + setActiveGuild: Dispatch>; } const AddGuildOverlay: FC = (props: AddGuildOverlayProps) => { - const { document, ui, guildsManager, addGuildData } = props; + const { guildsManager, addGuildData, setActiveGuild } = props; const rootRef = useRef(null); @@ -108,10 +106,9 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) return { result: null, errorMessage: 'Error adding new guild' }; } - const guildElement = await ui.addGuild(guildsManager, newGuild); - ElementsUtil.closeReactOverlay(document); - guildElement.click(); + setActiveGuild(newGuild); + ElementsUtil.closeReactOverlay(document); return { result: newGuild, errorMessage: null }; }, [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ] diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index a94f797..7342ac6 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -5,37 +5,10 @@ const LOG = Logger.create(__filename, electronConsole); import React from 'react'; -import * as FileType from 'file-type'; - -import Globals from '../../globals'; - -import ElementsUtil from './elements-util'; -import { Channel } from '../../data-types'; -import CombinedGuild from '../../guild-combined'; -import Q from '../../q-module'; -import ReactHelper from './react-helper'; - export interface HTMLElementWithRemoveSelf extends HTMLElement { removeSelf: (() => void); } -interface CreateUploadOverlayProps { - guild: CombinedGuild; - channel: Channel; - resourceName: string; - resourceBuffFunc: (() => Promise); - resourceSizeFunc: (() => Promise | number); -} - -interface BindImageUploadEventsProps { - maxSize: number; - acceptedMimeTypes: string[]; - onChangeStart: (() => Promise | void); - onCleared: (() => Promise | void); - onError: ((message: string) => Promise | void); - onLoaded: ((buff: Buffer, src: string) => Promise | void); -} - export default class BaseElements { // Scraped directly from discord (#) static TEXT_CHANNEL_ICON = ( @@ -268,220 +241,5 @@ export default class BaseElements { y="-5" transform="rotate(-135)" /> - ) - - static createContextMenu(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf { - const element = ReactHelper.createElementFromJSX( -
-
{content}
-
- ) as HTMLElementWithRemoveSelf; - - element.addEventListener('mousedown', (e: Event) => { - e.stopPropagation(); // stop the bubble - }); - element.removeSelf = () => { - if (element.parentElement) { - element.parentElement.removeChild(element); - } - document.body.removeEventListener('mousedown', element.removeSelf); - }; - document.body.addEventListener('mousedown', element.removeSelf); - return element as HTMLElementWithRemoveSelf; - } - - // 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 - const element: HTMLElementWithRemoveSelf = ReactHelper.createElementFromJSX(
{content}
) as HTMLElementWithRemoveSelf; - element.removeSelf = () => { - if (element.parentElement) { - element.parentElement.removeChild(element); - } - window.removeEventListener('keydown', onKeyEscape); - } - const onKeyEscape = (e: KeyboardEvent) => { - if (e.key == 'Escape') { - element.removeSelf(); - } - }; - window.addEventListener('keydown', onKeyEscape); - element.addEventListener('mouseup', () => { - if (wasDownInternal) { - wasDownInternal = false; - return; - } - element.removeSelf(); - }); - - q.$$$(element, '.content').addEventListener('click', (e) => { - e.stopPropagation(); // prevent the element from closing if the content is clicked on - }); - q.$$$(element, '.content').addEventListener('mousedown', (e) => { - wasDownInternal = true; - }); - - return element as HTMLElementWithRemoveSelf; - } - - static createUploadOverlay(document: Document, props: CreateUploadOverlayProps): HTMLElementWithRemoveSelf { - const q = new Q(document); - - const { guild, channel, resourceName, resourceBuffFunc, resourceSizeFunc } = props; - - const element = BaseElements.createOverlay(document, ( -
-
- {resourceName} -
-
{resourceName}
-
? B
-
-
-
-
-
-
-
Upload to #{channel.name}
-
-
-
- )); - - q.$$$(element, '.text-input').addEventListener('keydown', async (e) => { - if (e.key == 'Enter' && !e.shiftKey) { - e.preventDefault(); - q.$$$(element, '.button.upload').click(); - } - }); - q.$$$(element, '.text-input').addEventListener('keyup', (e) => { - if (e.key == 'Backspace') { - if (q.$$$(element, '.text-input').innerText == '\n') { // sometimes, a \n gets left behind - q.$$$(element, '.text-input').innerText = ''; - } - } - }); - - let sending = false; // prevent double-clicking from sending 2 messages - q.$$$(element, '.button.upload').addEventListener('click', async () => { - if (sending) { - return; - } - sending = true; - if (!guild.isSocketVerified()) { - LOG.warn('client attempted to send message with resource while not verified'); - q.$$$(element, '.error').innerText = 'Not Connected to Server'; - q.$$$(element, '.button.upload').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.upload'), 400); - sending = false; - return; - } - q.$$$(element, '.button.upload').innerText = 'Uploading...'; - let text: string | null = q.$$$(element, '.text-input').innerText; - text = text.trim(); // this is not done server-side, just a client-side 'feature' - if (text == '') { - text = null; - } - if (text && text.length > Globals.MAX_TEXT_MESSAGE_LENGTH) { - LOG.warn('Attempted to upload oversized resource text message: ' + text.length + ' / ' + Globals.MAX_TEXT_MESSAGE_LENGTH + ' characters'); - q.$$$(element, '.error').innerText = 'Text too long: ' + text.length + ' / ' + Globals.MAX_TEXT_MESSAGE_LENGTH + ' characters'; - q.$$$(element, '.button.upload').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.upload'), 400); - sending = false; - return; - } - let resourceBuff: Buffer; - try { - resourceBuff = await resourceBuffFunc(); - } catch (e) { - LOG.error('Error loading resource', e); - q.$$$(element, '.error').innerText = 'Error loading resource. Was it moved?'; - q.$$$(element, '.button.upload').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.upload'), 400); - sending = false; - return; - } - if (resourceBuff.length > Globals.MAX_RESOURCE_SIZE) { - LOG.warn('Attempted to upload oversized resource: ' + ElementsUtil.humanSize(resourceBuff.length) + ' > ' + ElementsUtil.humanSize(Globals.MAX_RESOURCE_SIZE)); - q.$$$(element, '.error').innerText = 'Resource too large: ' + ElementsUtil.humanSize(resourceBuff.length) + ' > ' + ElementsUtil.humanSize(Globals.MAX_RESOURCE_SIZE); - q.$$$(element, '.button.upload').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.upload'), 400); - sending = false; - return; - } - try { - await guild.requestSendMessageWithResource(channel.id, text, resourceBuff, resourceName); - } catch (e) { - q.$$$(element, '.error').innerText = 'Error uploading resource.'; - q.$$$(element, '.button.upload').innerText = 'Try Again'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.upload'), 400); - sending = false; - return; - } - element.removeSelf(); // get rid of the overlay after the message gets sent - }); - (async () => { - try { - const size = await resourceSizeFunc(); - q.$$$(element, '.title .size').innerText = ElementsUtil.humanSize(size); - } catch (e) { - LOG.error('Error fetching file stat', e); - q.$$$(element, '.title .size').innerText = 'Unknown Size'; - } - })(); - (async () => { - if ( - resourceName.toLowerCase().endsWith('.png') || - resourceName.toLowerCase().endsWith('.jpg') || - resourceName.toLowerCase().endsWith('.jpeg') || - resourceName.toLowerCase().endsWith('.gif') - ) { - try { - const resourceBuff = await resourceBuffFunc(); - const resourceSrc = await ElementsUtil.getImageBufferSrc(resourceBuff); - (q.$$$(element, '.title img') as HTMLImageElement).src = resourceSrc; - } catch (e) { - LOG.error('Error loading image resource', e); - (q.$$$(element, 'img.avatar') as HTMLImageElement).src = './img/file-improved.svg'; // not the error icon here - } - } else { - (q.$$$(element, '.title img') as HTMLImageElement).src = './img/file-improved.svg'; - } - })(); - return element; - } - - static bindImageUploadEvents(element: HTMLInputElement, props: BindImageUploadEventsProps): void { - const { maxSize, acceptedMimeTypes, onChangeStart, onCleared, onError, onLoaded } = props; - - element.addEventListener('change', async () => { - await onChangeStart(); - const files = element.files; - if (!files || files.length == 0) { - await onCleared(); - return; - } - const file = files[0] as File; // only one file at a time - if (file.size > maxSize) { - await onError('Image Too Large. ' + ElementsUtil.humanSize(file.size) + ' > ' + ElementsUtil.humanSize(maxSize)); - return; - } - const buff = Buffer.from(await file.arrayBuffer()); - const typeResult = await FileType.fromBuffer(buff); - if (!typeResult || !acceptedMimeTypes.includes(typeResult.mime)) { - await onError('Invalid Image Type. Accepted Types: ' + acceptedMimeTypes.map(type => type.replace('image/', '')).join(', ')); - return; - } - let src: string | null = null; - try { - src = await ElementsUtil.getImageBufferSrc(buff); - } catch (e) { - await onError('Unable Parse Image'); - return; - } - await onLoaded(buff, src); - }); - } + ); } diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index 9c3e96c..d25f86d 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -1,8 +1,5 @@ -import * as path from 'path'; import * as fs from 'fs/promises'; -import * as electron from 'electron'; - import * as electronRemote from '@electron/remote'; const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; @@ -12,9 +9,7 @@ import * as FileType from 'file-type'; import * as uuid from 'uuid'; import Util from '../../util'; -import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; -import { ShouldNeverHappenError } from '../../data-types'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -28,41 +23,12 @@ export interface IAlignment { bottom?: string; } -interface IHTMLElementWithRemovalType extends HTMLElement { - manualRemoval?: boolean; -} - interface SimpleQElement { tag: 'span', content: (SimpleQElement | string)[], class: string | null } -interface CreateDownloadListenerProps { - downloadBuff?: Buffer; - guild?: CombinedGuild; - resourceId?: string; - resourceName: string; - downloadStartFunc: (() => Promise | void); - downloadFailFunc?: ((message: string) => Promise | void); - writeStartFunc: (() => Promise | void); - writeFailFunc: ((e: unknown) => Promise | void); - successFunc: ((path: string) => Promise | void); -} - -interface ShakingOnSubmitProps { - doSubmit: () => Promise, - setSubmitting: React.Dispatch>, - setSubmitFailed: React.Dispatch>, - setShaking: React.Dispatch> -} - -async function sleep(ms: number): Promise { - return await new Promise((resolve, reject) => { - setTimeout(resolve, ms); - }); -} - export default class ElementsUtil { static calendarFormats = { sameDay: '[Today at] HH:mm', @@ -78,23 +44,6 @@ export default class ElementsUtil { return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'; } - // See https://stackoverflow.com/q/1125292/ - static setCursorToEnd(element: HTMLElement): void { - const range = document.createRange(); - range.selectNodeContents(element); - range.collapse(false); // false for end rather than start - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } - - // Shakes an element for specified ms - static async shakeElement(element: HTMLElement, ms: number): Promise { - element.classList.add('shaking-horizontal'); - await sleep(ms); - element.classList.remove('shaking-horizontal'); - } - // TODO: Remove this in favor of useSubmitButton style stuff from ReactHelper // 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 @@ -369,17 +318,9 @@ export default class ElementsUtil { } } - static mountReactComponent(element: Element, component: JSX.Element) { - ReactDOM.render(component, element); - } - - static unmountReactComponent(element: Element) { - ReactDOM.unmountComponentAtNode(element); - } - static presentReactOverlay(document: Document, overlay: JSX.Element) { // for aids reasons, the click event gets sent through to the overlay so we're just adding a sleep - // here to break the event loop. Hopefully this gets better when we don't have to do a seperate render piece. + // here to break the event loop. Hopefully this gets better when we don't have to do a seperate render piece // and we handle overlays through 100% react (async () => { await Util.sleep(0); @@ -390,84 +331,4 @@ export default class ElementsUtil { static closeReactOverlay(document: Document) { ReactDOM.unmountComponentAtNode(document.querySelector('#react-overlays') as HTMLElement); } - - static bindHoverableContextElement( - hoverElement: HTMLElement, - contextElement: IHTMLElementWithRemovalType, - rootElement: HTMLElement, - alignment: IAlignment, - neverRemove?: boolean - ): void { - hoverElement.addEventListener('mouseenter', () => { - document.body.appendChild(contextElement); - ElementsUtil.alignContextElement(contextElement, rootElement, alignment); - }); - - if (neverRemove) { - LOG.warn('hoverable context menu created with neverRemove flag set.'); - return; - } - - hoverElement.addEventListener('mouseleave', () => { - if (contextElement.parentElement && !contextElement.manualRemoval) { - contextElement.parentElement.removeChild(contextElement); - } - }); - } - - static createDownloadListener(props: CreateDownloadListenerProps): (() => Promise) { - const { - downloadBuff, // pre-downloaded buffer to save rather than submit a download request (downloadStartFunc still required) - guild, resourceId, resourceName, - downloadStartFunc, downloadFailFunc, - writeStartFunc, writeFailFunc, - successFunc - } = props; - - let downloading = false; - let downloadPath: string | null = null; - return async () => { - if (downloading) return; - if (downloadPath && await Util.exists(downloadPath)) { - electron.shell.showItemInFolder(downloadPath); - return; - } - - downloading = true; - - await downloadStartFunc(); - let resourceBuff: Buffer; - if (downloadBuff) { - resourceBuff = downloadBuff; - } else { - 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'); - try { - resourceBuff = (await guild.fetchResource(resourceId)).data; - } catch (e: unknown) { - LOG.error('Error downloading resource', { e }); - if (downloadFailFunc) await downloadFailFunc(e as string); - downloading = false; - return; - } - } - - await writeStartFunc(); - try { - const availableName = await Util.getAvailableFileName(Globals.DOWNLOAD_DIR, resourceName); - downloadPath = path.join(Globals.DOWNLOAD_DIR, availableName); - await fs.writeFile(downloadPath, resourceBuff); - } catch (e) { - LOG.error('Error writing download file', e); - await writeFailFunc(e); - downloadPath = null; - downloading = false; - return; - } - - await successFunc(downloadPath); - - downloading = false; - } - } } diff --git a/src/client/webapp/elements/require/guild-manager-subscriptions.ts b/src/client/webapp/elements/require/guilds-manager-subscriptions.ts similarity index 100% rename from src/client/webapp/elements/require/guild-manager-subscriptions.ts rename to src/client/webapp/elements/require/guilds-manager-subscriptions.ts diff --git a/src/client/webapp/elements/sections/guild-list-container.tsx b/src/client/webapp/elements/sections/guild-list-container.tsx new file mode 100644 index 0000000..1230224 --- /dev/null +++ b/src/client/webapp/elements/sections/guild-list-container.tsx @@ -0,0 +1,94 @@ +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, { Dispatch, FC, SetStateAction, useRef } from 'react'; +import CombinedGuild from '../../guild-combined'; +import GuildsManager from '../../guilds-manager'; +import GuildList from '../lists/guild-list'; +import ReactHelper from '../require/react-helper'; +import fs from 'fs/promises'; +import ElementsUtil from '../require/elements-util'; +import AddGuildOverlay from '../overlays/overlay-add-guild'; +import ErrorMessageOverlay from '../overlays/overlay-error-message'; +import BasicHover, { BasicHoverSide } from '../contexts/context-hover-basic'; +import BaseElements from '../require/base-elements'; + +export interface GuildListContainerProps { + guildsManager: GuildsManager; + guilds: CombinedGuild[]; + activeGuild: CombinedGuild | null; + setActiveGuild: Dispatch>; +} + +const GuildListContainer: FC = (props: GuildListContainerProps) => { + const { guildsManager, guilds, activeGuild, setActiveGuild } = props; + + const addGuildRef = useRef(null); + + const [ contextHover, onMouseEnter, onMouseLeave ] = ReactHelper.useContextHover(() => { + return ( + +
+
{BaseElements.TAB_LEFT}
+
Add Guild
+
+
+ ); + }, []); + + const [ onAddGuildClickCallback ] = ReactHelper.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({ + title: 'Select Guild File', + defaultPath: '.', // TODO: better path name + properties: [ 'openFile' ], + filters: [ + { name: 'Cordis Guild Files', extensions: [ 'cordis' ] } + ] + }); + + if (result.canceled || result.filePaths.length === 0) return; + + const filePath = result.filePaths[0] as string; + const fileText = (await fs.readFile(filePath)).toString('utf-8'); // TODO: error handling here + + const addGuildData = JSON.parse(fileText); + if ( + typeof addGuildData !== 'object' || + typeof addGuildData?.name !== 'string' || + typeof addGuildData?.url !== 'string' || + typeof addGuildData?.cert !== 'string' || + typeof addGuildData?.token !== 'string' || + typeof addGuildData?.expires !== 'number' || + typeof addGuildData?.iconSrc !== 'string' + ) { + LOG.debug('bad guild data:', { addGuildData, fileText }); + ElementsUtil.presentReactOverlay(document, ); + } else { + ElementsUtil.presentReactOverlay(document, ); + } + }, [ guildsManager ]); + + return ( +
+ +
+
+ +
+ {contextHover} +
+ ); +} + +export default GuildListContainer; diff --git a/src/client/webapp/elements/sections/guild.tsx b/src/client/webapp/elements/sections/guild.tsx index d8cd09c..65b5ec4 100644 --- a/src/client/webapp/elements/sections/guild.tsx +++ b/src/client/webapp/elements/sections/guild.tsx @@ -29,6 +29,10 @@ const GuildElement: FC = (props: GuildElementProps) => { const [ activeChannel, setActiveChannel ] = useState(null); + useEffect(() => { + setActiveChannel(null); + }, [ guild ]); + useEffect(() => { if (activeChannel === null) { // initial active channel is the first one in the list diff --git a/src/client/webapp/elements/sections/guilds-manager.tsx b/src/client/webapp/elements/sections/guilds-manager.tsx new file mode 100644 index 0000000..68895bb --- /dev/null +++ b/src/client/webapp/elements/sections/guilds-manager.tsx @@ -0,0 +1,37 @@ +import React, { FC, useEffect, useState } from 'react'; +import CombinedGuild from '../../guild-combined'; +import GuildsManager from '../../guilds-manager'; +import { useGuildListSubscription } from '../require/guilds-manager-subscriptions'; +import GuildElement from './guild'; +import GuildListContainer from './guild-list-container'; + +export interface GuildsManagerElementProps { + guildsManager: GuildsManager; +} + +const GuildsManagerElement: FC = (props: GuildsManagerElementProps) => { + const { guildsManager } = props; + + const [ guilds ] = useGuildListSubscription(guildsManager); + const [ activeGuild, setActiveGuild ] = useState(null); + + useEffect(() => { + if (activeGuild === null) { + // initial active channel is the first one in the list + if (guilds && guilds.length > 0) { + setActiveGuild(guilds[0] as CombinedGuild); + } + } + }, [ guilds, activeGuild ]); + + return ( +
+ + {activeGuild && } +
+ ); +} + +export default GuildsManagerElement; diff --git a/src/client/webapp/index.html b/src/client/webapp/index.html index d2813d6..be47ce3 100644 --- a/src/client/webapp/index.html +++ b/src/client/webapp/index.html @@ -34,17 +34,7 @@
-
-
-
-
-
-
- -
-
-
-
+
diff --git a/src/client/webapp/preload.ts b/src/client/webapp/preload.tsx similarity index 53% rename from src/client/webapp/preload.ts rename to src/client/webapp/preload.tsx index 7a3bffc..88cff46 100644 --- a/src/client/webapp/preload.ts +++ b/src/client/webapp/preload.tsx @@ -12,17 +12,14 @@ import GuildsManager from './guilds-manager'; import Globals from './globals'; -import UI from './ui'; -import { GuildMetadata } from './data-types'; import Q from './q-module'; import bindWindowButtonEvents from './elements/events-window-buttons'; -import bindAddGuildEvents from './elements/events-add-guild'; import PersonalDB from './personal-db'; import MessageRAMCache from './message-ram-cache'; import ResourceRAMCache from './resource-ram-cache'; -import CombinedGuild from './guild-combined'; -import { AutoVerifierChangesType } from './auto-verifier'; -import { mountBaseComponents } from './elements/mounts'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import GuildsManagerElement from './elements/sections/guilds-manager'; LOG.silly('modules loaded'); @@ -61,55 +58,17 @@ window.addEventListener('DOMContentLoaded', () => { const guildsManager = new GuildsManager(messageRAMCache, resourceRAMCache, personalDB); await guildsManager.init(); - LOG.silly('controller initialized'); + LOG.silly('guilds manager initialized'); const q = new Q(document); - const ui = new UI(document, q); LOG.silly('action classes initialized'); bindWindowButtonEvents(q); - bindAddGuildEvents(document, q, ui, guildsManager); LOG.silly('events bound'); - mountBaseComponents(q, ui, guildsManager); - - // Add guild icons - await ui.setGuilds(guildsManager, guildsManager.guilds); - - if (guildsManager.guilds.length > 0) { - // Click on the first guild in the list - q.$('#guild-list .guild').click(); - } - - // Change Events - - guildsManager.on('update-metadata', async (guild: CombinedGuild, guildMeta: GuildMetadata) => { - LOG.debug(`g#${guild.id} metadata updated`); - - // Not using withPotentialError since keeping the old icon is a fine fallback - if (guildMeta.iconResourceId) { - try { - const icon = await guild.fetchResource(guildMeta.iconResourceId); - await ui.updateGuildIcon(guild, icon.data); - } catch (e) { - LOG.error('Error fetching new guild icon', e); - // Keep the old guild icon, just log an error. - // Should go through another try after a restart - } - } - }); - - // Conflict Events - - guildsManager.on('conflict-metadata', async (guild: CombinedGuild, changesType: AutoVerifierChangesType, oldGuildMeta: GuildMetadata, newGuildMeta: GuildMetadata) => { - LOG.debug('metadata conflict', { newGuildMeta: newGuildMeta }); - (async () => { - const icon = await guild.fetchResource(newGuildMeta.iconResourceId); - await ui.updateGuildIcon(guild, icon.data); - })(); - }); + ReactDOM.render(, q.$('.guilds-manager-anchor')); })(); }); diff --git a/src/client/webapp/styles/contexts.scss b/src/client/webapp/styles/contexts.scss index 653bd84..ee07926 100644 --- a/src/client/webapp/styles/contexts.scss +++ b/src/client/webapp/styles/contexts.scss @@ -100,6 +100,7 @@ border-radius: 4px; } + .add-guild-hover, .guild-hover { display: flex; align-items: center; diff --git a/src/client/webapp/styles/guild-list.scss b/src/client/webapp/styles/guild-list.scss index 7ee93ed..57266f9 100644 --- a/src/client/webapp/styles/guild-list.scss +++ b/src/client/webapp/styles/guild-list.scss @@ -1,6 +1,6 @@ @import "theme.scss"; -#guild-list-container { +.guild-list-container { display: flex; flex-flow: column; overflow-y: scroll; @@ -12,10 +12,6 @@ overflow-y: scroll; } -#guild-list { - display: none; -} - .guild-list { display: flex; flex-flow: column; @@ -25,7 +21,7 @@ display: none; } -#add-guild, +.add-guild, .guild-list .guild { cursor: pointer; margin-bottom: 8px; @@ -33,7 +29,7 @@ align-items: center; } -#add-guild .pill, +.add-guild .pill, .guild-list .guild .pill { background-color: $header-primary; width: 8px; @@ -52,12 +48,12 @@ height: 8px; } -#add-guild:hover .pill, +.add-guild:hover .pill, .guild-list .guild:not(.active):hover .pill { height: 20px; } -#add-guild img, +.add-guild img, .guild-list .guild img { width: 48px; height: 48px; @@ -65,7 +61,7 @@ transition: border-radius .1s ease-in-out; } -#add-guild:hover img, +.add-guild:hover img, .guild-list .guild:hover img, .guild-list .guild.active img { border-radius: 16px; diff --git a/src/client/webapp/styles/content.scss b/src/client/webapp/styles/guilds-manager.scss similarity index 76% rename from src/client/webapp/styles/content.scss rename to src/client/webapp/styles/guilds-manager.scss index b8a8ae7..cb09876 100644 --- a/src/client/webapp/styles/content.scss +++ b/src/client/webapp/styles/guilds-manager.scss @@ -1,6 +1,6 @@ @import "theme.scss"; -#content { +.guilds-manager { flex: 1; display: flex; } diff --git a/src/client/webapp/styles/styles.scss b/src/client/webapp/styles/styles.scss index 5674025..7f96109 100644 --- a/src/client/webapp/styles/styles.scss +++ b/src/client/webapp/styles/styles.scss @@ -11,13 +11,13 @@ @import "channel-list.scss"; @import "channel.scss"; @import "connection.scss"; -@import "content.scss"; @import "contexts.scss"; @import "error-indicator.scss"; @import "general.scss"; @import "members.scss"; @import "overlays.scss"; @import "scrollbars.scss"; +@import "guilds-manager.scss"; @import "guild-list.scss"; @import "guild.scss"; @import "shake.scss"; diff --git a/src/client/webapp/ui.ts b/src/client/webapp/ui.ts deleted file mode 100644 index 9796e32..0000000 --- a/src/client/webapp/ui.ts +++ /dev/null @@ -1,85 +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 ConcurrentQueue from '../../concurrent-queue/concurrent-queue'; - -import ElementsUtil from './elements/require/elements-util'; - -import CombinedGuild from './guild-combined'; -import { ShouldNeverHappenError } from './data-types'; -import Q from './q-module'; -import createGuildListGuild from './elements/guild-list-guild'; -import GuildsManager from './guilds-manager'; -import { mountGuildComponents } from './elements/mounts'; - -interface SetMessageProps { - atTop: boolean; - atBottom: boolean; -} - -export default class UI { - public activeGuild: CombinedGuild | null = null; - - private document: Document; - private q: Q; - - public constructor(document: Document, q: Q) { - this.document = document; - this.q = q; - } - - // Use non-concurrent queues to prevent concurrent updates to parts of the view - // This is effectively a javascript version of a 'lock' - // These 'locks' should be called from working code rather than the updating functions themselves to work properly - private _guildsLock = new ConcurrentQueue(1); - - public setActiveGuild(guild: CombinedGuild): void { - if (this.activeGuild !== null) { - const prev = this.q.$_('#guild-list .guild[data-id="' + this.activeGuild.id + '"]'); - if (prev) { - prev.classList.remove('active'); - } - } - const next = this.q.$('#guild-list .guild[data-id="' + guild.id + '"]'); - next.classList.add('active'); - - this.activeGuild = guild; - mountGuildComponents(this.q, guild); - } - - public async setGuilds(guildsManager: GuildsManager, guilds: CombinedGuild[]): Promise { - await this._guildsLock.push(() => { - Q.clearChildren(this.q.$('#guild-list')); - for (const guild of guilds) { - const element = createGuildListGuild(this.document, this.q, this, guildsManager, guild); - this.q.$('#guild-list').appendChild(element); - } - }); - } - - public async addGuild(guildsManager: GuildsManager, guild: CombinedGuild): Promise { - let element: HTMLElement | null = null; - await this._guildsLock.push(() => { - element = createGuildListGuild(this.document, this.q, this, guildsManager, guild) as HTMLElement; - this.q.$('#guild-list').appendChild(element); - }); - if (element == null) throw new ShouldNeverHappenError('element was not set'); - return element; - } - - public async removeGuild(guild: CombinedGuild): Promise { - await this._guildsLock.push(() => { - const element = this.q.$_('#guild-list .guild[data-id="' + guild.id + '"]'); - element?.parentElement?.removeChild(element); - }); - } - - public async updateGuildIcon(guild: CombinedGuild, iconBuff: Buffer): Promise { - await this._guildsLock.push(async () => { - const iconElement = this.q.$('#guild-list .guild[data-id="' + guild.id + '"] img') as HTMLImageElement; - iconElement.src = await ElementsUtil.getImageBufferSrc(iconBuff); - }); - } -} diff --git a/src/client/webapp/util.ts b/src/client/webapp/util.ts index a9a6a54..0c4604f 100644 --- a/src/client/webapp/util.ts +++ b/src/client/webapp/util.ts @@ -9,7 +9,6 @@ import * as path from 'path'; import * as socketio from 'socket.io-client'; import Q from './q-module'; -import createErrorIndicator from './elements/error-indicator'; interface WithPotentialErrorParams { @@ -84,49 +83,6 @@ export default class Util { } } - // Will return once the fetchFunc was called successfully OR the request was canceled. - // If the error indicator element is removed from the error container, this will reject - // Note: Detected using MutationObservers - // If the error container removed from the document, this could result in memory leaks - // NOTE: This should NOT be called within an element lock - static async withPotentialError(q: Q, params: WithPotentialErrorParams): Promise { - const { taskFunc, errorIndicatorAddFunc, errorContainer, errorClasses, errorMessage } = params; - // eslint-disable-next-line no-async-promise-executor - return await new Promise(async (resolve, reject) => { - try { - await taskFunc(); - resolve(null); - } catch (e) { - LOG.debug('params', { params }); - LOG.error('caught potential error', e); - const errorIndicatorElement = createErrorIndicator(q, { - container: errorContainer, - classes: errorClasses, - message: errorMessage, - taskFunc: taskFunc, - resolveFunc: resolve, - rejectFunc: reject - }); - await errorIndicatorAddFunc(errorIndicatorElement); - if (errorIndicatorElement.parentElement != errorContainer) { - if (errorIndicatorElement.parentElement) { - errorIndicatorElement.parentElement.removeChild(errorIndicatorElement); - } - LOG.error('error indicator was not added to the error container'); - reject(new Error('bad errorIndicatorAddFunc')); - } - } - }); - } - - static async withPotentialErrorWarnOnCancel(q: Q, params: WithPotentialErrorParams) { - try { - await Util.withPotentialError(q, params) - } catch (e) { - LOG.warn('with potential error canceled:', e); - } - } - static async sleep(ms: number): Promise { return await new Promise((resolve, reject) => { setTimeout(resolve, ms);