guilds-manager element
Almost to the root node!
This commit is contained in:
parent
9410fe3829
commit
0d36daacca
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 ]);
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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} />);
|
||||
}
|
@ -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 ]
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
94
src/client/webapp/elements/sections/guild-list-container.tsx
Normal file
94
src/client/webapp/elements/sections/guild-list-container.tsx
Normal 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;
|
@ -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
|
||||
|
37
src/client/webapp/elements/sections/guilds-manager.tsx
Normal file
37
src/client/webapp/elements/sections/guilds-manager.tsx
Normal 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;
|
@ -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>
|
||||
|
@ -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'));
|
||||
})();
|
||||
});
|
||||
|
@ -100,6 +100,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.add-guild-hover,
|
||||
.guild-hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -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;
|
||||
|
@ -1,6 +1,6 @@
|
||||
@import "theme.scss";
|
||||
|
||||
#content {
|
||||
.guilds-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
@ -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";
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user