diff --git a/.vscode/cordis.code-snippets b/.vscode/cordis.code-snippets index a3de50b..fb995b5 100644 --- a/.vscode/cordis.code-snippets +++ b/.vscode/cordis.code-snippets @@ -19,7 +19,7 @@ "description": "Generally you'll want this when you create a TSX file", "prefix": [ "tsx" ], "body": [ - "import React, { FC } from 'react'", + "import React, { FC } from 'react';", "", "export interface ${1:Element}Props {", "\ttext: string;", diff --git a/makefile b/makefile index 130481c..b9d80ae 100644 --- a/makefile +++ b/makefile @@ -22,8 +22,8 @@ test: node ./node_modules/ts-jest/cli.js move: - cp -r ./src/client/webapp/font ./dist/client/webapp/font - cp -r ./src/client/webapp/img ./dist/client/webapp/img + cp -r ./src/client/webapp/font/* ./dist/client/webapp/font + cp -r ./src/client/webapp/img/* ./dist/client/webapp/img cp ./src/client/webapp/index.html ./dist/client/webapp/index.html cp -r ./src/server/scripts/resources ./dist/server/scripts/resources cp -r ./src/server/ssl ./dist/server/ssl diff --git a/src/client/webapp/elements/components/input-file.tsx b/src/client/webapp/elements/components/input-file.tsx new file mode 100644 index 0000000..d350946 --- /dev/null +++ b/src/client/webapp/elements/components/input-file.tsx @@ -0,0 +1,39 @@ +import * as electronRemote from '@electron/remote'; +const electronConsole = electronRemote.getGlobal('console') as Console; +import Logger from '../../../../logger/logger'; +const LOG = Logger.create(__filename, electronConsole); + +import React, { FC, ReactNode } from 'react'; + +interface FileInputProps { + setBuff: React.Dispatch>; + setName: React.Dispatch>; + children: ReactNode; +} + +const FileInput: FC = (props: FileInputProps) => { + const { setBuff, setName, children } = props; + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) { + LOG.debug('no files'); + return; + } + const file = files[0] as File; + const buff = Buffer.from(await file.arrayBuffer()); + setBuff(buff); + setName(file.name); + } + + return ( +
+ +
+ ); +} + +export default FileInput; diff --git a/src/client/webapp/elements/components/overlay.tsx b/src/client/webapp/elements/components/overlay.tsx index e9e6ad6..b25b9bd 100644 --- a/src/client/webapp/elements/components/overlay.tsx +++ b/src/client/webapp/elements/components/overlay.tsx @@ -9,13 +9,19 @@ import ReactHelper from '../require/react-helper'; interface OverlayProps { childRootRef: RefObject; // clicks outside this ref will close the overlay + close?: () => void; children: React.ReactNode; } const Overlay: FC = (props: OverlayProps) => { - const { childRootRef, children } = props; + const { childRootRef, close, children } = props; const removeSelf = useCallback(() => { - ElementsUtil.closeReactOverlay(document); + if (close) { + close(); + } else { + LOG.warn('closing react overlay with ElementsUtil (deprecated)'); + ElementsUtil.closeReactOverlay(document); + } }, []); ReactHelper.useCloseWhenClickedOutsideEffect(childRootRef, () => { removeSelf(); }); diff --git a/src/client/webapp/elements/events-text-input.ts b/src/client/webapp/elements/events-text-input.ts deleted file mode 100644 index 62eebda..0000000 --- a/src/client/webapp/elements/events-text-input.ts +++ /dev/null @@ -1,146 +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 ElementsUtil from './require/elements-util.js'; -import Globals from '../globals'; - -import { Channel } from '../data-types'; -import Q from '../q-module'; -import UI from '../ui'; -import createUploadOverlayFromPath from './overlay-upload-path'; -import createUploadOverlayFromDataTransferItem from './overlay-upload-datatransfer'; -import createUploadDropTarget from './overlay-upload-drop-target'; -import CombinedGuild from '../guild-combined'; - -export default function bindTextInputEvents(document: Document, q: Q, ui: UI): void { - // Send Current Channel Messages - let sendingMessage = false; - async function sendCurrentTextMessage() { - if (sendingMessage) return; - if (ui.activeGuild === null) return; - if (ui.activeChannel === null) return; - - const text = q.$('#text-input').innerText.trim(); // trimming is not done server-side, just a client-side 'feature' - if (text == '') return; - - sendingMessage = true; - - const guild = ui.activeGuild as CombinedGuild; - const channel = ui.activeChannel as Channel; - - if (!guild.isSocketVerified()) { - LOG.warn('client attempted to send message while not verified'); - q.$('#send-error').innerText = 'Not Connected to Server'; - await ElementsUtil.shakeElement(q.$('#send-error'), 400); - sendingMessage = false; - return; - } - - if (text.length > Globals.MAX_TEXT_MESSAGE_LENGTH) { - LOG.warn('skipping sending oversized message: ' + text.length + ' > ' + Globals.MAX_TEXT_MESSAGE_LENGTH + ' characters'); - q.$('#send-error').innerText = 'Message too long: ' + text.length + ' > ' + Globals.MAX_TEXT_MESSAGE_LENGTH + ' characters'; - await ElementsUtil.shakeElement(q.$('#send-error'), 400); - sendingMessage = false; - return; - } - - await ui.lockMessages(guild, channel, async () => { - q.$('#text-input').removeAttribute('contenteditable'); - q.$('#text-input').classList.add('sending'); - try { - await guild.requestSendMessage(channel.id, text); - - q.$('#send-error').innerText = ''; - q.$('#text-input').innerText = ''; - } catch (e) { - LOG.error('Error sending message', e); - q.$('#send-error').innerText = 'Error sending message'; - await ElementsUtil.shakeElement(q.$('#send-error'), 400); - } - q.$('#text-input').classList.remove('sending'); - q.$('#text-input').setAttribute('contenteditable', 'plaintext-only'); - q.$('#text-input').focus(); - }); - - sendingMessage = false; - } - - q.$('#text-input').addEventListener('keydown', async (e) => { - if (!sendingMessage) { - q.$('#send-error').innerText = ''; // clear out the sending error if the message changes - } - if (e.key == 'Enter' && !e.shiftKey) { - e.preventDefault(); - await sendCurrentTextMessage(); - } - }); - - q.$('#text-input').addEventListener('keyup', (e) => { - if (e.key == 'Backspace') { - if (q.$('#text-input').innerText == '\n') { // sometimes, a \n gets left behind - q.$('#text-input').innerText = ''; - } - } - }); - - q.$('#send-error').addEventListener('click', () => { - q.$('#send-error').innerText = ''; - }); - - // Open resource select dialog when resource-input-button is clicked - let selectingResources = false; - q.$('#resource-input-button').addEventListener('click', async () => { - if (ui.activeGuild === null) return; - if (ui.activeChannel === null) return; - - if (selectingResources) { - return; - } - selectingResources = true; - const result = await electronRemote.dialog.showOpenDialog({ - title: 'Select Resource', - defaultPath: 'D:\\development\\cordis\\client-server\\server\\data', // TODO: not hardcoded - properties: [ 'openFile' ] - }); - // TODO: multiple files do consecutive overlays? - if (!result.canceled && result.filePaths.length > 0) { - const element = createUploadOverlayFromPath(document, ui.activeGuild, ui.activeChannel, result.filePaths[0] as string); - document.body.appendChild(element); - q.$$$(element, '.text-input').focus(); - } - selectingResources = false; - }); - - // Open upload resource dialog when an image is pasted - window.addEventListener('paste', (e) => { - if (ui.activeGuild === null) return; - if (ui.activeChannel === null) return; - - let fileTransferItem: DataTransferItem | null = null; - for (const item of (e as ClipboardEvent).clipboardData?.items ?? []) { - if (item.kind == 'file') { - e.preventDefault(); // don't continue the paste - fileTransferItem = item; - break; - } - } - if (fileTransferItem) { - const element = createUploadOverlayFromDataTransferItem(document, ui.activeGuild, ui.activeChannel, fileTransferItem); - document.body.appendChild(element); - q.$$$(element, '.text-input').focus(); - } - }); - - // TODO: drag+drop new server files? - document.addEventListener('dragenter', () => { - if (ui.activeGuild === null) return; - if (ui.activeChannel === null) return; - - if (q.$('.overlay .drop-target')) return; - const element = createUploadDropTarget(document, q, ui.activeGuild, ui.activeChannel); - if (!element) return; - document.body.appendChild(element); - }); -} diff --git a/src/client/webapp/elements/require/base-elements.tsx b/src/client/webapp/elements/require/base-elements.tsx index 624042b..e805727 100644 --- a/src/client/webapp/elements/require/base-elements.tsx +++ b/src/client/webapp/elements/require/base-elements.tsx @@ -252,6 +252,24 @@ export default class BaseElements { ); + static REMOVE_ATTACHMENT_X = ( + + + + + + ) + static createContextMenu(document: Document, content: JSX.Element): HTMLElementWithRemoveSelf { const element = ReactHelper.createElementFromJSX(
diff --git a/src/client/webapp/elements/sections/send-message.tsx b/src/client/webapp/elements/sections/send-message.tsx index d1142c2..6ad2317 100644 --- a/src/client/webapp/elements/sections/send-message.tsx +++ b/src/client/webapp/elements/sections/send-message.tsx @@ -3,11 +3,48 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, FormEvent, KeyboardEvent, RefObject, useCallback, useMemo, useRef, useState } from 'react' +import React, { FC, FormEvent, KeyboardEvent, RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { Channel } from '../../data-types'; import CombinedGuild from '../../guild-combined'; import BaseElements from '../require/base-elements'; import ReactHelper from '../require/react-helper'; +import * as FileType from 'file-type'; +import ElementsUtil from '../require/elements-util'; +import FileInput from '../components/input-file'; + +export interface AttachmentPreviewProps { + attachmentBuff: Buffer; + attachmentName: string; + remove: () => void; +} + +const AttachmentPreview: FC = (props: AttachmentPreviewProps) => { + const { attachmentBuff, attachmentName, remove } = props; + + const [ attachmentImgSrc ] = ReactHelper.useOneTimeAsyncAction( + async () => { + const type = await FileType.fromBuffer(attachmentBuff); + if (!type) return './img/file-icon.png'; + if (type.mime === 'image/gif' || type.mime === 'image/jpeg' || type.mime === 'image/png') { + // show a preview of the image + return await ElementsUtil.getImageSrcFromBufferFailSoftly(attachmentBuff); + } else { + return './img/file-icon.png'; + } + }, + './img/loading.svg', + [ attachmentBuff ] + ); + + return ( +
+ {attachmentName} +
{attachmentName}
+
{BaseElements.REMOVE_ATTACHMENT_X}
+
+ ); +} + export interface SendMessageProps { guild: CombinedGuild; @@ -21,20 +58,31 @@ const SendMessage: FC = (props: SendMessageProps) => { const [ text, setText ] = useState(''); const [ enabled, setEnabled ] = useState(true); + const [ attachmentBuff, setAttachmentBuff ] = useState(null); + const [ attachmentName, setAttachmentName ] = useState(null); + const [ sendCallable ] = ReactHelper.useAsyncVoidCallback( async (isMounted: RefObject) => { if (!enabled) return; setEnabled(false); // TODO: Deal with errors (toasts are probably the best way) - await guild.requestSendMessage(channel.id, text); - if (!isMounted.current) return; + if (attachmentBuff && attachmentName) { + await guild.requestSendMessageWithResource(channel.id, text === '' ? null : text, attachmentBuff, attachmentName) + if (!isMounted.current) return; + setAttachmentBuff(null); + setAttachmentName(null); + } else if (text !== '') { + await guild.requestSendMessage(channel.id, text); + if (!isMounted.current) return; + } + setText(''); if (contentEditableRef.current) contentEditableRef.current.innerText = ''; setEnabled(true); if (contentEditableRef.current) contentEditableRef.current.focus(); }, - [ enabled, guild, channel, text ] + [ enabled, guild, channel, text, attachmentBuff, attachmentName ] ); const onTextInput = useCallback((e: FormEvent) => { @@ -46,7 +94,18 @@ const SendMessage: FC = (props: SendMessageProps) => { e.preventDefault(); sendCallable(); } - }, [ text ]); + }, [ sendCallable ]); + + + const removeAttachment = useCallback(() => { + setAttachmentBuff(null); + setAttachmentName(null); + }, []); + + const attachmentPreview = useMemo(() => { + if (!attachmentBuff || !attachmentName) return null; + return + }, [ attachmentBuff, attachmentName ]); // WARNING: The types on this are funky because of react's lack of explicit support for 'plaintext-only' const contentEditableType = useMemo(() => { @@ -61,13 +120,21 @@ const SendMessage: FC = (props: SendMessageProps) => { return (
-
{BaseElements.SEND_MESSAGE_ATTACH}
-
+ {attachmentPreview} + {attachmentPreview &&
} +
+
+ + {BaseElements.SEND_MESSAGE_ATTACH} + +
+
+
); diff --git a/src/client/webapp/guild-combined.ts b/src/client/webapp/guild-combined.ts index 6508fb1..091053f 100644 --- a/src/client/webapp/guild-combined.ts +++ b/src/client/webapp/guild-combined.ts @@ -365,6 +365,7 @@ export default class CombinedGuild extends EventEmitter { await this.socketGuild.requestSendMessage(channelId, text); } + // TODO: Change to "withAttachment" async requestSendMessageWithResource(channelId: string, text: string | null, resource: Buffer, resourceName: string): Promise { await this.socketGuild.requestSendMessageWithResource(channelId, text, resource, resourceName); } @@ -377,7 +378,6 @@ export default class CombinedGuild extends EventEmitter { await this.socketGuild.requestSetAvatar(avatar); } - // TODO: Rename Server -> Guild async requestSetGuildName(guildName: string): Promise { await this.socketGuild.requestSetGuildName(guildName); } diff --git a/src/client/webapp/img/close-16x16.svg b/src/client/webapp/img/close-16x16.svg new file mode 100644 index 0000000..bcbebf6 --- /dev/null +++ b/src/client/webapp/img/close-16x16.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + diff --git a/src/client/webapp/img/x-10x10.svg b/src/client/webapp/img/x-10x10.svg new file mode 100644 index 0000000..713a644 --- /dev/null +++ b/src/client/webapp/img/x-10x10.svg @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/src/client/webapp/img/x-16x16.svg b/src/client/webapp/img/x-16x16.svg new file mode 100644 index 0000000..a91fbe0 --- /dev/null +++ b/src/client/webapp/img/x-16x16.svg @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/src/client/webapp/index.html b/src/client/webapp/index.html index 27d4204..100f050 100644 --- a/src/client/webapp/index.html +++ b/src/client/webapp/index.html @@ -55,20 +55,6 @@
-
-
-
-
- - -
-
-
-
-
-
-
-
diff --git a/src/client/webapp/preload.ts b/src/client/webapp/preload.ts index 4fd27ef..b9b5059 100644 --- a/src/client/webapp/preload.ts +++ b/src/client/webapp/preload.ts @@ -17,7 +17,6 @@ import Actions from './actions'; import { Changes, ConnectionInfo, GuildMetadata, Member, Resource, Token } from './data-types'; import Q from './q-module'; import bindWindowButtonEvents from './elements/events-window-buttons'; -import bindTextInputEvents from './elements/events-text-input'; import bindAddGuildEvents from './elements/events-add-guild'; import PersonalDB from './personal-db'; import MessageRAMCache from './message-ram-cache'; @@ -71,7 +70,6 @@ window.addEventListener('DOMContentLoaded', () => { LOG.silly('action classes initialized'); bindWindowButtonEvents(q); - bindTextInputEvents(document, q, ui); bindAddGuildEvents(document, q, ui, guildsManager); LOG.silly('events bound'); diff --git a/src/client/webapp/styles/channel-feed.scss b/src/client/webapp/styles/channel-feed.scss index e9932e8..0c52cfc 100644 --- a/src/client/webapp/styles/channel-feed.scss +++ b/src/client/webapp/styles/channel-feed.scss @@ -8,40 +8,98 @@ $borderRadius: 8px; height: 0; .send-message-input { - display: flex; - align-items: flex-start; position: absolute; left: 0; bottom: 0; margin: 0 16px 16px 16px; box-sizing: border-box; width: calc(100% - 32px); + + display: flex; + flex-flow: column; + align-items: flex-start; + color: $text-normal; background-color: $channeltextarea-background; border-radius: $borderRadius; - .resource-input-button { - cursor: pointer; - margin: 12px; + .attachment-preview { + position: relative; + margin: 16px 16px 0 16px; + padding: 8px; + border-radius: 8px; + box-sizing: border-box; + max-width: calc(100% - 32px); + background-color: $background-secondary; - svg { - width: 24px; - height: 24px; - } + img.preview { + max-width: 128px; + max-height: 128px; + border-radius: 4px; + object-fit: contain; + } + + .name { + line-height: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .remove { + position: absolute; + right: -8px; + top: -8px; + border-radius: 8px; + cursor: pointer; + color: $background-button-negative; + + &:hover { + color: $background-button-negative-hover; + } + + svg { + width: 20px; + height: 20px; + } + } + } + + .divider { + box-sizing: border-box; + width: 100%; + margin: 16px 0 0 0; + height: 1px; + background-color: $interactive-muted; } - .text-input { - flex: 1; - padding: 12px 12px 12px 0; - max-height: 300px; - overflow-y: scroll; - overflow-wrap: anywhere; - white-space: pre; + .send-message-input-row { + display: flex; + align-items: flex-start; - &.disabled { - color: $text-sending; - } - } + .attachment-input-button { + cursor: pointer; + margin: 12px; + + svg { + width: 24px; + height: 24px; + } + } + + .text-input { + flex: 1; + padding: 12px 12px 12px 0; + max-height: 300px; + overflow-y: scroll; + overflow-wrap: anywhere; + white-space: pre; + + &.disabled { + color: $text-sending; + } + } + } } } @@ -56,84 +114,3 @@ $borderRadius: 8px; display: flex; flex-direction: column; } - -#channel-feed-input-wrapper { - display: none; // for testing - position: relative; - height: 0; -} - -#channel-feed-input { - position: absolute; - bottom: 0; - left: 0; - box-sizing: border-box; - padding-bottom: 16px; - margin: 0 16px; - width: calc(100% - 32px); - display: flex; - flex-flow: column-reverse; - background-color: $background-primary; - border-radius: 8px; -} - -#error-bar { - position: relative; - height: 0; -} - -#send-error:not(:empty) { - cursor: pointer; - position: absolute; - left: 0; - bottom: 0; - margin-bottom: 8px; - padding: 8px; - border-radius: 8px; - line-height: 1; - border: 1px solid $error; - background-color: $background-secondary; - color: $header-primary; -} - -#input-bar { - font-family: Whitney; - background-color: $channeltextarea-background; - border-radius: 8px; - display: flex; - align-items: center; -} - -#resource-input-button { - color: $text-normal; - cursor: pointer; - margin: 10px; - - - svg { - width: 24px; - height: 24px; - } -} - -#text-input { - flex: 1; - padding: 12px 12px 12px 0; - color: $text-normal; - overflow-wrap: anywhere; - overflow-y: scroll; - max-height: 300px; - - - &:focus { - outline: none; - } - - &.sending { - color: $text-sending; - } - - &::before { - color: $text-muted; - } -} diff --git a/src/client/webapp/ui.ts b/src/client/webapp/ui.ts index a6e1d6d..0965b0e 100644 --- a/src/client/webapp/ui.ts +++ b/src/client/webapp/ui.ts @@ -113,9 +113,6 @@ export default class UI { public async setActiveChannel(guild: CombinedGuild, channel: Channel): Promise { await this.lockChannels(guild, () => { - // Channel Name + Flavor Text Header + Text Input Placeholder - this.q.$('#text-input').setAttribute('data-placeholder', 'Message #' + channel.name); - this.activeChannel = channel; mountGuildChannelComponents(this.q, guild, channel); });