diff --git a/src/client/webapp/elements/context-menu-conn.ts b/src/client/webapp/elements/context-menu-conn.tsx similarity index 64% rename from src/client/webapp/elements/context-menu-conn.ts rename to src/client/webapp/elements/context-menu-conn.tsx index 5de910d..30ffbe7 100644 --- a/src/client/webapp/elements/context-menu-conn.ts +++ b/src/client/webapp/elements/context-menu-conn.tsx @@ -1,3 +1,5 @@ +import React from 'react'; +import ReactHelper from './require/react-helper.js'; import ElementsUtil from './require/elements-util.js'; import BaseElements from './require/base-elements.js'; @@ -9,20 +11,20 @@ import CombinedGuild from '../guild-combined.js'; export default function createConnectionContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild) { const statuses = [ 'online', 'away', 'busy', 'invisible' ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - let content: any[] = [ - { class: 'item personalize', content: [ - { class: 'icon', content: { tag: 'img', src: './img/pencil-icon.png' } }, - { content: 'Personalize' } - ] }, - { class: 'item-spacer' } + let content: JSX.Element[] = [ +
+
+
Personalize
+
, +
]; - content = content.concat(statuses.map(status => { - return { class: 'item ' + status, content: [ - { class: 'status-circle' }, - { class: 'status-text', content: status } - ] }; - })); - const element = BaseElements.createContextMenu(document, { class: 'member-context', content: content }); + content = content.concat(statuses.map(status => ( +
+
+
{status}
+
+ ))); + const element = BaseElements.createContextMenu(document,
{content}
); q.$$$(element, '.personalize').addEventListener('click', async () => { element.removeSelf(); diff --git a/src/client/webapp/elements/context-menu-guild-title.ts b/src/client/webapp/elements/context-menu-guild-title.tsx similarity index 70% rename from src/client/webapp/elements/context-menu-guild-title.ts rename to src/client/webapp/elements/context-menu-guild-title.tsx index efbe45d..5d7376f 100644 --- a/src/client/webapp/elements/context-menu-guild-title.ts +++ b/src/client/webapp/elements/context-menu-guild-title.tsx @@ -16,30 +16,37 @@ import createCreateChannelOverlay from './overlay-create-channel'; import createTokenLogOverlay from './overlay-token-log'; import CombinedGuild from '../guild-combined'; -export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): HTMLElement { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createGuildTitleContextMenu(document: Document, q: Q, ui: UI, guild: CombinedGuild): Element { if (ui.activeConnection === null) { LOG.warn('no active connection when creating guild title context menu'); - return q.create({}) as HTMLElement; + return ReactHelper.createElementFromJSX(
); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - const menuItems: any[] = []; + const menuItems: JSX.Element[] = []; if (ui.activeConnection.privileges.includes('modify_profile')) { - menuItems.push({ class: 'item guild-settings', content: [ - { class: 'icon', content: BaseElements.Q_COG }, - 'Guild Settings' - ] }); + menuItems.push( +
+
{BaseElements.COG}
+
Guild Settings
+
+ ); } if (ui.activeConnection.privileges.includes('modify_channels')) { if (ui.activeConnection.privileges.includes('modify_profile')) { - menuItems.push({ class: 'item-spacer' }); + menuItems.push(
); } - menuItems.push({ class: 'item create-channel', content: [ - { class: 'icon', content: BaseElements.Q_CREATE }, - 'Create Channel' - ] }); + menuItems.push( +
+
{BaseElements.CREATE}
+
Create Channel
+
+ ); } if (ui.activeConnection.privileges.includes('modify_members')) { @@ -47,21 +54,25 @@ export default function createGuildTitleContextMenu(document: Document, q: Q, ui ui.activeConnection.privileges.includes('modify_profile') || ui.activeConnection.privileges.includes('modify_channels') ) { - menuItems.push({ class: 'item-spacer' }); + menuItems.push(
); } - menuItems.push({ class: 'item create-invite-token', content: [ - { class: 'icon', content: BaseElements.Q_TOKEN }, - 'Create Invite Token' - ] }); - menuItems.push({ class: 'item token-log', content: [ - { class: 'icon', content: BaseElements.Q_TOKEN }, - 'Token Log' - ] }); + menuItems.push( +
+
{BaseElements.TOKEN}
+
Create Invite Token
+
+ ); + menuItems.push( +
+
{BaseElements.TOKEN}
+
Token Log
+
+ ); } - const element = BaseElements.createContextMenu(document, { - class: 'guild-title-context', content: menuItems - }); + const element = BaseElements.createContextMenu(document, ( +
{menuItems}
+ )); if (ui.activeConnection.privileges.includes('modify_profile')) { q.$$$(element, '.item.guild-settings').addEventListener('click', async () => { diff --git a/src/client/webapp/elements/context-menu-guild.ts b/src/client/webapp/elements/context-menu-guild.tsx similarity index 80% rename from src/client/webapp/elements/context-menu-guild.ts rename to src/client/webapp/elements/context-menu-guild.tsx index d3eabb1..d87845c 100644 --- a/src/client/webapp/elements/context-menu-guild.ts +++ b/src/client/webapp/elements/context-menu-guild.tsx @@ -10,12 +10,14 @@ 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, { - class: 'guild-context', content: [ - { class: 'item red leave-guild', content: 'Leave Guild' } - ] - }); + const element = BaseElements.createContextMenu(document, ( +
+
Leave Guild
+
+ )); q.$$$(element, '.leave-guild').addEventListener('click', async () => { element.removeSelf(); diff --git a/src/client/webapp/elements/context-menu-img.ts b/src/client/webapp/elements/context-menu-img.tsx similarity index 83% rename from src/client/webapp/elements/context-menu-img.ts rename to src/client/webapp/elements/context-menu-img.tsx index 91bdee2..2bcb917 100644 --- a/src/client/webapp/elements/context-menu-img.ts +++ b/src/client/webapp/elements/context-menu-img.tsx @@ -10,6 +10,8 @@ import ElementsUtil from './require/elements-util'; import Q from '../q-module'; import CombinedGuild from '../guild-combined'; +import React from 'react'; + export default function createImageContextMenu( document: Document, q: Q, @@ -21,10 +23,12 @@ export default function createImageContextMenu( isPreview: boolean ): HTMLElement { // TODO: try/catch around sharp? - const contextMenu = BaseElements.createContextMenu(document, { class: 'image', content: [ - { class: 'item copy-image', content: 'Copy Image' + (isPreview ? ' Preview' : '') }, - { class: 'item save-image', content: 'Save Image' + (isPreview ? ' Preview' : '') }, - ] }); + const contextMenu = BaseElements.createContextMenu(document, ( +
+
Copy Image{isPreview ? ' Preview' : ''}
+
Save Image{isPreview ? ' Preview' : ''}
+
+ )); q.$$$(contextMenu, '.copy-image').addEventListener('click', async () => { q.$$$(contextMenu, '.copy-image').innerText = 'Copying...'; let nativeImage: electron.NativeImage; diff --git a/src/client/webapp/elements/error-indicator.ts b/src/client/webapp/elements/error-indicator.tsx similarity index 78% rename from src/client/webapp/elements/error-indicator.ts rename to src/client/webapp/elements/error-indicator.tsx index 251218c..1c257ac 100644 --- a/src/client/webapp/elements/error-indicator.ts +++ b/src/client/webapp/elements/error-indicator.tsx @@ -5,6 +5,8 @@ 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; @@ -16,19 +18,19 @@ export interface CreateErrorIndicatorProps { } // resolveFunc and rejectFunc should be the resolve/reject functions from the withPotentialError promise -export default function createErrorIndicator(q: Q, props: CreateErrorIndicatorProps): HTMLElement { +export default function createErrorIndicator(q: Q, props: CreateErrorIndicatorProps): Element { props.classes = props.classes ?? []; const { container, classes, message, taskFunc, resolveFunc, rejectFunc } = props; - const element = q.create({ - class: [ 'error-indicator', ...classes ], content: [ - { tag: 'img', src: './img/error.png', alt: 'error' }, - { content: [ - { content: message }, - { class: 'retry-button', content: 'Try Again' } - ] } - ] - }) as HTMLElement; + const element = ReactHelper.createElementFromJSX( +
+ error +
+
{message}
+
Try Again
+
+
+ ); const observer = new MutationObserver(() => { if (element.parentElement == null) { diff --git a/src/client/webapp/elements/events-guild-title.ts b/src/client/webapp/elements/events-guild-title.ts index 546aa74..f6ce845 100644 --- a/src/client/webapp/elements/events-guild-title.ts +++ b/src/client/webapp/elements/events-guild-title.ts @@ -13,7 +13,7 @@ export default function bindAddGuildTitleEvents(document: Document, q: Q, ui: UI !ui.activeConnection.privileges.includes('modify_members') ) return; - const contextMenu = createGuildTitleContextMenu(document, q, ui, ui.activeGuild); + const contextMenu = createGuildTitleContextMenu(document, q, ui, ui.activeGuild) as HTMLElement; document.body.appendChild(contextMenu); ElementsUtil.alignContextElement(contextMenu, q.$('#guild-name-container'), { top: 'bottom', centerX: 'centerX' }); }); diff --git a/src/client/webapp/elements/guild-list-guild.ts b/src/client/webapp/elements/guild-list-guild.tsx similarity index 90% rename from src/client/webapp/elements/guild-list-guild.ts rename to src/client/webapp/elements/guild-list-guild.tsx index 97076bb..562035e 100644 --- a/src/client/webapp/elements/guild-list-guild.ts +++ b/src/client/webapp/elements/guild-list-guild.tsx @@ -14,11 +14,16 @@ import createGuildContextMenu from './context-menu-guild'; import GuildsManager from '../guilds-manager'; import CombinedGuild from '../guild-combined'; -export default function createGuildListGuild(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild) { - const element = q.create({ class: 'guild', 'data-id': guild.id, 'meta-name': guild.id, content: [ - { class: 'pill' }, - { tag: 'img', src: './img/loading.svg', alt: 'guild' }, // src is set later by script.js - ] }) as HTMLElement; +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createGuildListGuild(document: Document, q: Q, ui: UI, guildsManager: GuildsManager, guild: CombinedGuild): Element { + const element = ReactHelper.createElementFromJSX( +
+
+ guild +
+ ) as HTMLElement; // Hover over for name + connection info (async () => { diff --git a/src/client/webapp/elements/message.ts b/src/client/webapp/elements/message.ts index e32f0f1..ce9051d 100644 --- a/src/client/webapp/elements/message.ts +++ b/src/client/webapp/elements/message.ts @@ -8,8 +8,9 @@ import createResourceMessageContinued from './msg-res-cont'; import createTextMessage from './msg-txt'; import createTextMessageContinued from './msg-txt-cont'; -export default function createMessage(document: Document, q: Q, guild: CombinedGuild, message: Message, lastMessage: Message | null): HTMLElement { - let element: HTMLElement; +// TODO: This is probably best as a react class +export default function createMessage(document: Document, q: Q, guild: CombinedGuild, message: Message, lastMessage: Message | null): Element { + let element: Element; if (message.hasResource()) { if (message.isImageResource()) { if (message.isContinued(lastMessage)) { diff --git a/src/client/webapp/elements/msg-img-res-cont.ts b/src/client/webapp/elements/msg-img-res-cont.tsx similarity index 72% rename from src/client/webapp/elements/msg-img-res-cont.ts rename to src/client/webapp/elements/msg-img-res-cont.tsx index 0896489..c19849c 100644 --- a/src/client/webapp/elements/msg-img-res-cont.ts +++ b/src/client/webapp/elements/msg-img-res-cont.tsx @@ -14,19 +14,26 @@ import createImageOverlay from './overlay-image'; import createImageContextMenu from './context-menu-img'; import CombinedGuild from '../guild-combined'; -export default function createImageResourceMessageContinued(document: Document, q: Q, guild: CombinedGuild, message: Message): HTMLElement { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createImageResourceMessageContinued(document: Document, q: Q, guild: CombinedGuild, message: Message): Element { if (!message.resourceId || !message.resourcePreviewId || !message.resourceName) { throw new ShouldNeverHappenError('Message is not a resource message'); } - const element = q.create({ class: 'message continued', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'timestamp', content: moment(message.sent).format('HH:mm') }, - { class: 'right', content: [ - { class: 'content image', style: `width: ${message.previewWidth}px; height: ${message.previewHeight}px;`, content: - { tag: 'img', src: './img/loading.svg', alt: message.resourceName } }, // src will be replaced later - { class: 'content text', content: ElementsUtil.parseMessageText(message.text ?? '') } - ] } - ] }) as HTMLElement; + const element = ReactHelper.createElementFromJSX( +
+
{moment(message.sent).format('HH:mm')}
+
+
+ {message.resourceName} +
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); + q.$$$(element, '.content.image').addEventListener('click', () => { document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string)); }); @@ -51,5 +58,6 @@ export default function createImageResourceMessageContinued(document: Document, (q.$$$(element, '.content.image img') as HTMLImageElement).src = './img/error.png'; } })(); + return element; } diff --git a/src/client/webapp/elements/msg-img-res.ts b/src/client/webapp/elements/msg-img-res.tsx similarity index 69% rename from src/client/webapp/elements/msg-img-res.ts rename to src/client/webapp/elements/msg-img-res.tsx index ca7937d..b28bdc8 100644 --- a/src/client/webapp/elements/msg-img-res.ts +++ b/src/client/webapp/elements/msg-img-res.tsx @@ -14,7 +14,10 @@ import createImageOverlay from './overlay-image'; import createImageContextMenu from './context-menu-img'; import CombinedGuild from '../guild-combined'; -export default function createImageResourceMessage(document: Document, q: Q, guild: CombinedGuild, message: Message) { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createImageResourceMessage(document: Document, q: Q, guild: CombinedGuild, message: Message): Element { if (!message.resourceId || !message.resourcePreviewId || !message.resourceName) { throw new ShouldNeverHappenError('Message is not a resource message'); } @@ -38,19 +41,25 @@ export default function createImageResourceMessage(document: Document, q: Q, gui }; } - const nameStyle = memberInfo.roleColor != null ? 'color: ' + memberInfo.roleColor : ''; - const element = q.create({ class: 'message', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'member-avatar', content: { tag: 'img', src: './img/loading.svg', alt: memberInfo.displayName } }, - { class: 'right', content: [ - { class: 'header', content: [ - { class: 'member-name', style: nameStyle, content: memberInfo.displayName }, - { class: 'timestamp', content: moment(message.sent).calendar(ElementsUtil.calendarFormats) } - ] }, - { class: 'content image', style: `width: ${message.previewWidth}px; height: ${message.previewHeight}px;`, content: - { tag: 'img', src: './img/loading.svg', alt: message.resourceName } }, // src will be replaced later - { class: 'content text', content: ElementsUtil.parseMessageText(message.text ?? '') } - ] } - ] }) as HTMLElement; + const nameStyle = memberInfo.roleColor != null ? { color: memberInfo.roleColor } : {}; + const element = ReactHelper.createElementFromJSX( +
+
+ {memberInfo.displayName} +
+
+
+
{memberInfo.displayName}
+
{moment(message.sent).calendar(ElementsUtil.calendarFormats)}
+
+
+ {message.resourceName} +
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); + q.$$$(element, '.content.image').addEventListener('click', (e) => { document.body.appendChild(createImageOverlay(document, q, guild, message.resourceId as string, message.resourceName as string)); }); diff --git a/src/client/webapp/elements/msg-res-cont.ts b/src/client/webapp/elements/msg-res-cont.tsx similarity index 62% rename from src/client/webapp/elements/msg-res-cont.ts rename to src/client/webapp/elements/msg-res-cont.tsx index 1275df3..72eb150 100644 --- a/src/client/webapp/elements/msg-res-cont.ts +++ b/src/client/webapp/elements/msg-res-cont.tsx @@ -5,24 +5,30 @@ import Q from "../q-module"; import ElementsUtil from "./require/elements-util"; -export default function createResourceMessageContinued(q: Q, guild: CombinedGuild, message: Message): HTMLElement { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createResourceMessageContinued(q: Q, guild: CombinedGuild, message: Message): Element { if (!message.resourceId || !message.resourceName) { throw new ShouldNeverHappenError('Message is not a resource message'); } - const element = q.create({ class: 'message continued', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'timestamp', content: moment(message.sent).format('HH:mm') }, - { class: 'right', content: [ - { class: 'content resource', content: [ - { tag: 'img', class: 'icon', src: './img/file-icon.png' }, // TODO: SVG based on content-type - { class: 'text', content: [ - { class: 'filename', content: message.resourceName }, - { class: 'download-status', content: 'Click to Download' }, - ] } - ] }, - { class: 'content text', content: ElementsUtil.parseMessageText(message.text ?? '') } - ] } - ] }) as HTMLElement; + const element = ReactHelper.createElementFromJSX( +
+
{moment(message.sent).format('HH:mm')}
+
+
+ file +
+
{message.resourceName}
+
Click to Download
+
+
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); + q.$$$(element, '.resource').addEventListener('click', ElementsUtil.createDownloadListener({ guild: guild, resourceId: message.resourceId, resourceName: message.resourceName, downloadStartFunc: () => { diff --git a/src/client/webapp/elements/msg-res.ts b/src/client/webapp/elements/msg-res.tsx similarity index 62% rename from src/client/webapp/elements/msg-res.ts rename to src/client/webapp/elements/msg-res.tsx index 07f9082..1665c04 100644 --- a/src/client/webapp/elements/msg-res.ts +++ b/src/client/webapp/elements/msg-res.tsx @@ -5,7 +5,10 @@ import Q from '../q-module'; import ElementsUtil from './require/elements-util'; -export default function createResourceMessage(q: Q, guild: CombinedGuild, message: Message): HTMLElement { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createResourceMessage(q: Q, guild: CombinedGuild, message: Message): Element { if (!message.resourceId || !message.resourceName) { throw new ShouldNeverHappenError('Message is not a resource message'); } @@ -29,24 +32,29 @@ export default function createResourceMessage(q: Q, guild: CombinedGuild, messag }; } - const nameStyle = memberInfo.roleColor ? 'color: ' + memberInfo.roleColor : ''; - const element = q.create({ class: 'message', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'member-avatar', content: { tag: 'img', src: './img/loading.svg', alt: memberInfo.displayName } }, - { class: 'right', content: [ - { class: 'header', content: [ - { class: 'member-name', style: nameStyle, content: memberInfo.displayName }, - { class: 'timestamp', content: moment(message.sent).calendar(ElementsUtil.calendarFormats) } - ] }, - { class: 'content resource', content: [ - { tag: 'img', class: 'icon', src: './img/file-icon.png' }, // TODO: SVG based on content-type - { class: 'text', content: [ - { class: 'filename', content: message.resourceName }, - { class: 'download-status', content: 'Click to Download' }, - ] } - ] }, - { class: 'content text', content: ElementsUtil.parseMessageText(message.text ?? '') } - ] } - ] }) as HTMLElement; + const nameStyle = memberInfo.roleColor != null ? { color: memberInfo.roleColor } : {}; + const element = ReactHelper.createElementFromJSX( +
+
+ {memberInfo.displayName} +
+
+
+
{memberInfo.displayName}
+
{moment(message.sent).calendar(ElementsUtil.calendarFormats)}
+
+
+ file +
+
{message.resourceName}
+
Click to Download
+
+
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); + q.$$$(element, '.resource').addEventListener('click', ElementsUtil.createDownloadListener({ guild: guild, resourceId: message.resourceId, resourceName: message.resourceName, downloadStartFunc: () => { diff --git a/src/client/webapp/elements/msg-txt-cont.ts b/src/client/webapp/elements/msg-txt-cont.ts deleted file mode 100644 index bf6aa32..0000000 --- a/src/client/webapp/elements/msg-txt-cont.ts +++ /dev/null @@ -1,15 +0,0 @@ -import moment from 'moment'; -import { Message } from '../data-types'; -import CombinedGuild from '../guild-combined'; -import Q from '../q-module.js'; - -import ElementsUtil from './require/elements-util.js'; - -export default function createTextMessageContinued(q: Q, guild: CombinedGuild, message: Message): HTMLElement { - return q.create({ class: 'message continued', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'timestamp', content: moment(message.sent).format('HH:mm') }, - { class: 'right', content: [ - { class: 'content text', content: ElementsUtil.parseMessageText(message.text ?? '') } - ] } - ] }) as HTMLElement; -} diff --git a/src/client/webapp/elements/msg-txt-cont.tsx b/src/client/webapp/elements/msg-txt-cont.tsx new file mode 100644 index 0000000..0d01840 --- /dev/null +++ b/src/client/webapp/elements/msg-txt-cont.tsx @@ -0,0 +1,20 @@ +import moment from 'moment'; +import { Message } from '../data-types'; +import CombinedGuild from '../guild-combined'; +import Q from '../q-module.js'; + +import ElementsUtil from './require/elements-util.js'; + +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createTextMessageContinued(q: Q, guild: CombinedGuild, message: Message): Element { + return ReactHelper.createElementFromJSX( +
+
{moment(message.sent).format('HH:mm')}
+
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); +} diff --git a/src/client/webapp/elements/msg-txt.ts b/src/client/webapp/elements/msg-txt.tsx similarity index 59% rename from src/client/webapp/elements/msg-txt.ts rename to src/client/webapp/elements/msg-txt.tsx index 95d2314..ad0ddba 100644 --- a/src/client/webapp/elements/msg-txt.ts +++ b/src/client/webapp/elements/msg-txt.tsx @@ -6,7 +6,10 @@ import { Message, Member, IDummyTextMessage } from '../data-types'; import Q from '../q-module'; import CombinedGuild from '../guild-combined'; -export default function createTextMessage(q: Q, guild: CombinedGuild, message: Message | IDummyTextMessage): HTMLElement { +import React from 'react'; +import ReactHelper from './require/react-helper'; + +export default function createTextMessage(q: Q, guild: CombinedGuild, message: Message | IDummyTextMessage): Element { let memberInfo: { roleColor: string | null, displayName: string, @@ -34,17 +37,21 @@ export default function createTextMessage(q: Q, guild: CombinedGuild, message: M }; } - const nameStyle = memberInfo.roleColor ? 'color: ' + memberInfo.roleColor : ''; - const element = q.create({ class: 'message', 'data-id': message.id, 'meta-member-id': message.member.id, 'data-guild-id': guild.id, content: [ - { class: 'member-avatar', content: { tag: 'img', src: './img/loading.svg', alt: memberInfo.displayName } }, - { class: 'right', content: [ - { class: 'header', content: [ - { class: 'member-name', style: nameStyle, content: memberInfo.displayName }, - { class: 'timestamp', content: moment(message.sent).calendar(ElementsUtil.calendarFormats) } - ] }, - { class: 'content text', content: ElementsUtil.parseMessageText(message.text || '') } - ] } - ] }) as HTMLElement; + const nameStyle = memberInfo.roleColor != null ? { color: memberInfo.roleColor } : {}; + const element = ReactHelper.createElementFromJSX( +
+
+ {memberInfo.displayName} +
+
+
+
{memberInfo.displayName}
+
{moment(message.sent).calendar(ElementsUtil.calendarFormats)}
+
+
{ElementsUtil.parseMessageText(message.text ?? '')}
+
+
+ ); (async () => { (q.$$$(element, '.member-avatar img') as HTMLImageElement).src = await ElementsUtil.getImageBufferFromResourceFailSoftly(guild, memberInfo.avatarResourceId); diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index 1e0258e..a71e3e3 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -13,8 +13,9 @@ 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'; -interface HTMLElementWithRemoveSelf extends HTMLElement { +export interface HTMLElementWithRemoveSelf extends HTMLElement { removeSelf: (() => void); } @@ -158,12 +159,26 @@ export default class BaseElements { ] } + static TOKEN = ( + + + + ); static Q_TOKEN = { ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 16 16', content: { ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M 7.9160156 0 A 8.0000004 8.0000004 0 0 0 1.8710938 2.8574219 A 8.0000004 8.0000004 0 0 0 2.8574219 14.128906 A 8.0000004 8.0000004 0 0 0 14.128906 13.142578 A 8.0000004 8.0000004 0 0 0 13.142578 1.8710938 A 8.0000004 8.0000004 0 0 0 7.9160156 0 z M 5.2324219 3.2851562 A 2.4000002 2.4000002 0 0 1 6.7851562 3.8476562 A 2.4000002 2.4000002 0 0 1 7.6054688 6.1015625 L 13.367188 10.9375 L 12.595703 11.857422 L 10.90625 12.003906 L 10.445312 11.619141 L 10.373047 10.773438 L 9.9121094 10.388672 L 9.0664062 10.462891 L 8.6074219 10.076172 L 8.5332031 9.2304688 L 8.0742188 8.8457031 L 7.2285156 8.9199219 L 6.0625 7.9414062 A 2.4000002 2.4000002 0 0 1 3.6992188 7.5253906 A 2.4000002 2.4000002 0 0 1 3.4042969 4.1425781 A 2.4000002 2.4000002 0 0 1 5.2324219 3.2851562 z' } } + static CREATE = ( + + + + ); static Q_CREATE = { ns: 'http://www.w3.org/2000/svg', tag: 'svg', width: 16, height: 16, viewBox: '0 0 16 16', content: { ns: 'http://www.w3.org/2000/svg', tag: 'path', fill: 'currentColor', 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', @@ -210,20 +225,26 @@ export default class BaseElements { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - static createContextMenu(document: Document, content: any): HTMLElementWithRemoveSelf { - const q = new Q(document); - + static createContextMenu(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const element = q.create({ class: 'context', content: { class: 'menu', content: content }}) as any; + const element = ReactHelper.createElementFromJSX( +
+
{content}
+
+ ) as HTMLElementWithRemoveSelf; + element.addEventListener('mousedown', (e: Event) => { e.stopPropagation(); // stop the bubble }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any element.removeSelf = () => { if (element.parentElement) { element.parentElement.removeChild(element); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any document.body.removeEventListener('mousedown', element.removeSelf); }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any document.body.addEventListener('mousedown', element.removeSelf); return element as HTMLElementWithRemoveSelf; } diff --git a/src/client/webapp/elements/require/elements-util.ts b/src/client/webapp/elements/require/elements-util.tsx similarity index 89% rename from src/client/webapp/elements/require/elements-util.ts rename to src/client/webapp/elements/require/elements-util.tsx index 0e8f7e7..3b822d3 100644 --- a/src/client/webapp/elements/require/elements-util.ts +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -5,7 +5,6 @@ import * as electron from 'electron'; import * as electronRemote from '@electron/remote'; const electronConsole = electronRemote.getGlobal('console') as Console; - import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); @@ -16,6 +15,8 @@ import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; import { ShouldNeverHappenError } from '../../data-types'; +import React from 'react'; + // TODO: pass-through Globals in init function // alignment: { // centerY: 'top' @@ -34,6 +35,13 @@ interface IHTMLElementWithRemovalType extends HTMLElement { manualRemoval?: boolean; } +interface SimpleQElement { + tag: 'span', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content: (SimpleQElement | string)[], + class: string | null +} + interface CreateDownloadListenerProps { downloadBuff?: Buffer; guild?: CombinedGuild; @@ -115,19 +123,19 @@ export default class ElementsUtil { } } - // creates spans to format the text (all in q.js element markup) + // creates spans to format the text (all in q.js element markup that gets converted to jsx [aka hypergaming]) // eslint-disable-next-line @typescript-eslint/no-explicit-any - static parseMessageText(text: string): any { + static parseMessageText(text: string): JSX.Element { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj = { tag: 'span', content: [] as any[], class: null }; + const obj: SimpleQElement = { tag: 'span', content: [] as (SimpleQElement | string)[], class: null }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stack: any[] = [ obj ]; + const stack: SimpleQElement[] = [ obj ]; let idx = 0; function makeEscape(regex: RegExp, len: number, str: string): { matcher: RegExp, response: ((i: number) => void)} { // function for readability return { matcher: regex, response: (i: number) => { - const top = stack[stack.length - 1]; + const top = stack[stack.length - 1] as SimpleQElement; if (idx != i) top.content.push(text.substring(idx, i)); top.content.push(str); idx = i + len; @@ -138,13 +146,14 @@ export default class ElementsUtil { return { matcher: regex, response: (i: number) => { - const top = stack[stack.length - 1]; + const top = stack[stack.length - 1] as SimpleQElement; if (idx != i) top.content.push(text.substring(idx, i)); if (top.class == cls) { // italic ends // TODO: optimise out empty elements stack.pop(); } else { // italic begins - const obj = { tag: 'span', class: cls, content: [] } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: SimpleQElement = { tag: 'span', class: cls, content: [] as any[] } top.content.push(obj); stack.push(obj); } @@ -175,13 +184,23 @@ export default class ElementsUtil { } if (!matched && idx != text.length) { // Add any remaining content - const top = stack[stack.length - 1]; + const top = stack[stack.length - 1] as SimpleQElement; top.content.push(text.substr(idx)); idx = text.length; } } + + function createReactElement(qjsObj: SimpleQElement | string): JSX.Element | string { + if (typeof qjsObj === 'string') { + return qjsObj + } else { + const content = qjsObj.content.map(qjsElement => createReactElement(qjsElement)); + return React.createElement(qjsObj.tag, { className: qjsObj.class }, content); + } + + } - return obj; + return createReactElement(obj) as JSX.Element; // since the first layer will always have a tag } // NOTE: both elements must be within the document or this function will not work diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 7b41d4a..a206362 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOMServer from "react-dom/server"; import { ShouldNeverHappenError } from "../../data-types"; @@ -6,7 +5,7 @@ import { ShouldNeverHappenError } from "../../data-types"; export default class ReactHelper { // eslint-disable-next-line @typescript-eslint/no-explicit-any - static createElementFromJSX(element: React.ReactElement>): Element { + static createElementFromJSX(element: JSX.Element): Element { // See also: https://www.codegrepper.com/code-examples/javascript/convert+a+string+to+html+element+in+js const htmlString = ReactDOMServer.renderToStaticMarkup(element); const parser = new DOMParser(); diff --git a/src/client/webapp/ui.ts b/src/client/webapp/ui.ts index ac7ccfe..9e8e340 100644 --- a/src/client/webapp/ui.ts +++ b/src/client/webapp/ui.ts @@ -34,7 +34,7 @@ export default class UI { public messagePairsGuild: CombinedGuild | null = null; public messagePairsChannel: Channel | { id: string } | null = null; - public messagePairs = new Map(); // messageId -> { message: Message, element: HTMLElement } + public messagePairs = new Map(); // messageId -> { message: Message, element: HTMLElement } private document: Document; private q: Q; @@ -311,7 +311,7 @@ export default class UI { } } - public async setChannelsErrorIndicator(guild: CombinedGuild, errorIndicatorElement: HTMLElement): Promise { + public async setChannelsErrorIndicator(guild: CombinedGuild, errorIndicatorElement: Element): Promise { await this.lockChannels(guild, () => { Q.clearChildren(this.q.$('#channel-list')); this.q.$('#channel-list').appendChild(errorIndicatorElement); @@ -381,7 +381,7 @@ export default class UI { const newStyle = member.roleColor ? 'color: ' + member.roleColor : null; const newName = member.displayName; // the extra query selectors may be overkill - for (const messageElement of this.q.$$(`.message[meta-member-id="${member.id}"]`)) { + for (const messageElement of this.q.$$(`.message[data-member-id="${member.id}"]`)) { const nameElement = this.q.$$$_(messageElement, '.member-name'); if (nameElement) { // continued messages will still show up but need to be skipped if (newStyle) nameElement.setAttribute('style', newStyle); @@ -396,19 +396,19 @@ export default class UI { await this.addMembers(guild, members, { clear: true }); } - public async setMembersErrorIndicator(guild: CombinedGuild, errorIndicatorElement: HTMLElement): Promise { + public async setMembersErrorIndicator(guild: CombinedGuild, errorIndicatorElement: Element): Promise { await this.lockMembers(guild, () => { Q.clearChildren(this.q.$('#guild-members')); this.q.$('#guild-members').appendChild(errorIndicatorElement); }); } - public getTopMessagePair(): { message: Message, element: HTMLElement } | null { + public getTopMessagePair(): { message: Message, element: Element } | null { const element = this.q.$$('#channel-feed .message')[0]; return element && this.messagePairs.get(element.getAttribute('data-id')) || null; } - public getBottomMessagePair(): { message: Message, element: HTMLElement } | null { + public getBottomMessagePair(): { message: Message, element: Element } | null { const messageElements = this.q.$$('#channel-feed .message'); const element = messageElements[messageElements.length - 1]; return element && this.messagePairs.get(element.getAttribute('data-id')) || null; @@ -688,19 +688,19 @@ export default class UI { } } - public async addMessagesErrorIndicatorBefore(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise { + public async addMessagesErrorIndicatorBefore(guild: CombinedGuild, channel: Channel, errorIndicatorElement: Element): Promise { await this.lockMessages(guild, channel, () => { this.q.$('#channel-feed').prepend(errorIndicatorElement); }); } - public async addMessagesErrorIndicatorAfter(guild: CombinedGuild, channel: Channel, errorIndicatorElement: HTMLElement): Promise { + public async addMessagesErrorIndicatorAfter(guild: CombinedGuild, channel: Channel, errorIndicatorElement: Element): Promise { await this.lockMessages(guild, channel, () => { this.q.$('#channel-feed').appendChild(errorIndicatorElement); }); } - public async setMessagesErrorIndicator(guild: CombinedGuild, channel: Channel | { id: string }, errorIndicatorElement: HTMLElement): Promise { + public async setMessagesErrorIndicator(guild: CombinedGuild, channel: Channel | { id: string }, errorIndicatorElement: Element): Promise { await this.lockMessages(guild, channel, () => { Q.clearChildren(this.q.$('#channel-feed')); this.q.$('#channel-feed').appendChild(errorIndicatorElement); diff --git a/src/client/webapp/util.ts b/src/client/webapp/util.ts index 76d81e4..0cb80f7 100644 --- a/src/client/webapp/util.ts +++ b/src/client/webapp/util.ts @@ -14,7 +14,7 @@ import createErrorIndicator from './elements/error-indicator'; interface WithPotentialErrorParams { taskFunc: (() => Promise), - errorIndicatorAddFunc: ((element: HTMLElement) => Promise), + errorIndicatorAddFunc: ((element: Element) => Promise), errorContainer: HTMLElement, errorClasses?: string[], errorMessage: string,