guilds-manager element

Almost to the root node!
This commit is contained in:
Michael Peters 2021-12-29 21:16:19 -06:00
parent 9410fe3829
commit 0d36daacca
22 changed files with 170 additions and 908 deletions

View File

@ -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, (
<div className="guild-context">
<div className="item red leave-guild">Leave Guild</div>
</div>
));
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;
}

View File

@ -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<void>);
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(
<div className={['error-indicator', ...classes].join(' ')}>
<img src="./img/error.png" alt="error"></img>
<div>
<div>{message}</div>
<div className="retry-button">Try Again</div>
</div>
</div>
);
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;
}

View File

@ -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, <AddGuildOverlay document={document} ui={ui} guildsManager={guildsManager} addGuildData={addGuildData} />);
} catch (e: unknown) {
LOG.error('Unable to parse guild data', e);
ElementsUtil.presentReactOverlay(document, <ErrorMessageOverlay title="Unable to parse guild file" message={(e as Error).message} />);
}
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);
}
});
}

View File

@ -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(
<div className="guild" data-id={guild.id}>
<div className="pill"></div>
<img src="./img/loading.svg" alt="guild"></img>
</div>
) 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;
}

View File

@ -69,7 +69,6 @@ const GuildListElement: FC<GuildListElementProps> = (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 ]);

View File

@ -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<SetStateAction<CombinedGuild | null>>;
}
const GuildList: FC<GuildListProps> = (props: GuildListProps) => {
const { guildsManager, ui } = props;
const [ guilds ] = useGuildListSubscription(guildsManager);
const [ activeGuild, setActiveGuild ] = useState<CombinedGuild | null>(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) => <GuildListElement key={guild.id} guildsManager={guildsManager} guild={guild} activeGuild={activeGuild} setSelfActiveGuild={() => { setActiveGuild(guild); } } />);
}, [ guilds ]);
return guilds.map((guild: CombinedGuild) => (
<GuildListElement
key={guild.id}
guildsManager={guildsManager} guild={guild}
activeGuild={activeGuild} setSelfActiveGuild={() => { setActiveGuild(guild); } }
/>
));
}, [ guildsManager, guilds, activeGuild ]);
return (
<div className="guild-list">

View File

@ -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'), <GuildList ui={ui} guildsManager={guildsManager} />);
}
export function mountGuildComponents(q: Q, guild: CombinedGuild) {
console.log(q.$('.guild-anchor'));
ElementsUtil.unmountReactComponent(q.$('.guild-anchor'));
ElementsUtil.mountReactComponent(q.$('.guild-anchor'), <GuildElement guild={guild} />);
}

View File

@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console;
import Logger from '../../../../logger/logger';
const LOG = Logger.create(__filename, electronConsole);
import React, { FC, 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<SetStateAction<CombinedGuild | null>>;
}
const AddGuildOverlay: FC<AddGuildOverlayProps> = (props: AddGuildOverlayProps) => {
const { document, ui, guildsManager, addGuildData } = props;
const { guildsManager, addGuildData, setActiveGuild } = props;
const rootRef = useRef<HTMLDivElement>(null);
@ -108,10 +106,9 @@ const AddGuildOverlay: FC<AddGuildOverlayProps> = (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 ]

View File

@ -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<Buffer>);
resourceSizeFunc: (() => Promise<number> | number);
}
interface BindImageUploadEventsProps {
maxSize: number;
acceptedMimeTypes: string[];
onChangeStart: (() => Promise<void> | void);
onCleared: (() => Promise<void> | void);
onError: ((message: string) => Promise<void> | void);
onLoaded: ((buff: Buffer, src: string) => Promise<void> | 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)" />
</svg>
)
static createContextMenu(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf {
const element = ReactHelper.createElementFromJSX(
<div className="context">
<div className="menu">{content}</div>
</div>
) 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(<div className="overlay">{content}</div>) 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, (
<div className="content upload">
<div className="title">
<img src="./img/loading.svg" alt={resourceName}></img>
<div className="right">
<div className="name">{resourceName}</div>
<div className="size">? B</div>
</div>
</div>
<div className="text-input" data-placeholder="Add a comment (optional)" contentEditable={'plaintext-only' as unknown as boolean /* React doesn't have plaintext-only in its typings (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/54779) */}></div>
<div className="lower">
<div className="error"></div>
<div className="buttons">
<div className="button upload">Upload to #{channel.name}</div>
</div>
</div>
</div>
));
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);
});
}
);
}

View File

@ -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> | void);
downloadFailFunc?: ((message: string) => Promise<void> | void);
writeStartFunc: (() => Promise<void> | void);
writeFailFunc: ((e: unknown) => Promise<void> | void);
successFunc: ((path: string) => Promise<void> | void);
}
interface ShakingOnSubmitProps {
doSubmit: () => Promise<boolean>,
setSubmitting: React.Dispatch<React.SetStateAction<boolean>>,
setSubmitFailed: React.Dispatch<React.SetStateAction<boolean>>,
setShaking: React.Dispatch<React.SetStateAction<boolean>>
}
async function sleep(ms: number): Promise<unknown> {
return await new Promise((resolve, reject) => {
setTimeout(resolve, ms);
});
}
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<void> {
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<void>) {
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;
}
}
}

View File

@ -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<SetStateAction<CombinedGuild | null>>;
}
const GuildListContainer: FC<GuildListContainerProps> = (props: GuildListContainerProps) => {
const { guildsManager, guilds, activeGuild, setActiveGuild } = props;
const addGuildRef = useRef<HTMLDivElement>(null);
const [ contextHover, onMouseEnter, onMouseLeave ] = ReactHelper.useContextHover(() => {
return (
<BasicHover relativeToRef={addGuildRef} side={BasicHoverSide.RIGHT}>
<div className="add-guild-hover">
<div className="tab">{BaseElements.TAB_LEFT}</div>
<div className="info">Add Guild</div>
</div>
</BasicHover>
);
}, []);
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, <ErrorMessageOverlay title="Unable to parse guild file" message={'bad guild data'} />);
} else {
ElementsUtil.presentReactOverlay(document, <AddGuildOverlay guildsManager={guildsManager} addGuildData={addGuildData} setActiveGuild={setActiveGuild} />);
}
}, [ guildsManager ]);
return (
<div className="guild-list-container">
<GuildList
guildsManager={guildsManager} guilds={guilds}
activeGuild={activeGuild} setActiveGuild={setActiveGuild}
/>
<div
ref={addGuildRef}
className="add-guild" onClick={onAddGuildClickCallback}
onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
>
<div className="pill"></div>
<img src="./img/add-guild-icon.png" />
</div>
{contextHover}
</div>
);
}
export default GuildListContainer;

View File

@ -29,6 +29,10 @@ const GuildElement: FC<GuildElementProps> = (props: GuildElementProps) => {
const [ activeChannel, setActiveChannel ] = useState<Channel | null>(null);
useEffect(() => {
setActiveChannel(null);
}, [ guild ]);
useEffect(() => {
if (activeChannel === null) {
// initial active channel is the first one in the list

View File

@ -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<GuildsManagerElementProps> = (props: GuildsManagerElementProps) => {
const { guildsManager } = props;
const [ guilds ] = useGuildListSubscription(guildsManager);
const [ activeGuild, setActiveGuild ] = useState<CombinedGuild | null>(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 (
<div className="guilds-manager">
<GuildListContainer
guildsManager={guildsManager} guilds={guilds}
activeGuild={activeGuild} setActiveGuild={setActiveGuild} />
{activeGuild && <GuildElement guild={activeGuild} />}
</div>
);
}
export default GuildsManagerElement;

View File

@ -34,17 +34,7 @@
</div>
</div>
</div>
<div id="content">
<div id="guild-list-container">
<div class="guild-list-anchor"></div>
<div id="guild-list"></div>
<div id="add-guild">
<div class="pill"></div>
<img src="./img/add-guild-icon.png">
</div>
</div>
<div class="guild-anchor"></div>
</div>
<div class="guilds-manager-anchor"></div>
<!-- it's important that this comes at the end so that these take prescedence over the other absolutely positioned objects-->
<div id="react-overlays"></div>
</body>

View File

@ -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(<GuildsManagerElement guildsManager={guildsManager} />, q.$('.guilds-manager-anchor'));
})();
});

View File

@ -100,6 +100,7 @@
border-radius: 4px;
}
.add-guild-hover,
.guild-hover {
display: flex;
align-items: center;

View File

@ -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;

View File

@ -1,6 +1,6 @@
@import "theme.scss";
#content {
.guilds-manager {
flex: 1;
display: flex;
}

View File

@ -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";

View File

@ -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<void>(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<void> {
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<HTMLElement> {
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<void> {
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<void> {
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);
});
}
}

View File

@ -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<unknown> {
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<unknown> {
return await new Promise((resolve, reject) => {
setTimeout(resolve, ms);