diff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js index c1d767d..2d0914e 100644 --- a/src/snek/static/chat-input.js +++ b/src/snek/static/chat-input.js @@ -1,69 +1,234 @@ -// Written by retoor@molodetz.nl -// This JavaScript class defines a custom HTML element for a chat input widget, featuring a text area and an upload button. It handles user input and triggers events for input changes and message submission. +import { app } from '../app.js'; -// Includes standard DOM manipulation methods; no external imports used. +class ChatInputComponent extends HTMLElement { + autoCompletions = { + 'example 1': () => { -// MIT License: This code is open-source and can be reused and distributed under the terms of the MIT License. + }, + 'example 2': () => { -class ChatInputElement extends HTMLElement { - _chatWindow = null - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - this.component = document.createElement('div'); - this.shadowRoot.appendChild(this.component); - } - set chatWindow(value){ - this._chatWindow = value + } + } - } - get chatWindow(){ - return this._chatWindow - } - get channelUid() { - return this.chatWindow.channel.uid - } - connectedCallback() { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = '/base.css'; - this.component.appendChild(link); + constructor() { + super(); + this.lastUpdateEvent = new Date(); + this.textarea = document.createElement("textarea"); + this._value = ""; + this.value = this.getAttribute("value") || ""; + this.previousValue = this.value; + this.lastChange = new Date(); + this.changed = false; + } - this.container = document.createElement('div'); - this.container.classList.add('chat-input'); - this.container.innerHTML = ` - <textarea placeholder="Type a message..." rows="2"></textarea> - <upload-button></upload-button> - `; - this.textBox = this.container.querySelector('textarea'); - this.uploadButton = this.container.querySelector('upload-button'); - this.uploadButton.chatInput = this - this.textBox.addEventListener('input', (e) => { - this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true })); - const message = e.target.value; - const button = this.container.querySelector('button'); - button.disabled = !message; - }); + get value() { + return this._value; + } - this.textBox.addEventListener('change', (e) => { - e.preventDefault(); - this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true })); - console.error(e.target.value); - }); + set value(value) { + this._value = value || ""; + this.textarea.value = this._value; + } - this.textBox.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - const message = e.target.value.trim(); - if (!message) return; - this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true })); - e.target.value = ''; - } - }); + resolveAutoComplete() { + let count = 0; + let value = null; + Object.keys(this.autoCompletions).forEach((key) => { + if (key.startsWith(this.value)) { + count++; + value = key; + } + }); + if (count == 1) + return value; + return null; + } - this.component.appendChild(this.container); - } + isActive() { + return document.activeElement === this.textarea; + } + + focus() { + this.textarea.focus(); + } + + connectedCallback() { + this.liveType = this.getAttribute("live-type") === "true"; + this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3; + this.channelUid = this.getAttribute("channel"); + this.messageUid = null; + + this.classList.add("chat-input"); + + this.textarea.setAttribute("placeholder", "Type a message..."); + this.textarea.setAttribute("rows", "2"); + + this.appendChild(this.textarea); + + this.uploadButton = document.createElement("upload-button"); + this.uploadButton.setAttribute("channel", this.channelUid); + this.uploadButton.addEventListener("upload", (e) => { + this.dispatchEvent(new CustomEvent("upload", e)); + }); + this.uploadButton.addEventListener("uploaded", (e) => { + this.dispatchEvent(new CustomEvent("uploaded", e)); + }); + + this.appendChild(this.uploadButton); + + this.textarea.addEventListener("keyup", (e) => { + if(e.key === 'Enter' && !e.shiftKey) { + this.value = '' + e.target.value = ''; + return + } + this.value = e.target.value; + this.changed = true; + this.update(); + }); + + this.textarea.addEventListener("keydown", (e) => { + this.value = e.target.value; + if (e.key === "Tab") { + e.preventDefault(); + let autoCompletion = this.resolveAutoComplete(); + if (autoCompletion) { + e.target.value = autoCompletion; + this.value = autoCompletion; + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + + const message = e.target.value; + this.messageUid = null; + this.value = ''; + this.previousValue = ''; + + if (!message) { + return; + } + + let autoCompletion = this.autoCompletions[message]; + if (autoCompletion) { + this.value = ''; + this.previousValue = ''; + e.target.value = ''; + autoCompletion(); + return; + } + + e.target.value = ''; + this.value = ''; + this.messageUid = null; + this.sendMessage(this.channelUid, message).then((uid) => { + this.messageUid = uid; + }); + } + }); + + this.changeInterval = setInterval(() => { + if (!this.liveType) { + return; + } + if (this.value !== this.previousValue) { + if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) { + this.value = ''; + this.previousValue = ''; + } + this.lastChange = new Date(); + } + this.update(); + }, 300); + + this.addEventListener("upload", (e) => { + this.focus(); + }); + this.addEventListener("uploaded", function (e) { + let message = ""; + e.detail.files.forEach((file) => { + message += `[${file.name}](/channel/attachment/${file.relative_url})`; + }); + app.rpc.sendMessage(this.channelUid, message); + }); + } + + trackSecondsBetweenEvents(event1Time, event2Time) { + const millisecondsDifference = event2Time.getTime() - event1Time.getTime(); + return millisecondsDifference / 1000; + } + + newMessage() { + if (!this.messageUid) { + this.messageUid = '?'; + } + + this.sendMessage(this.channelUid, this.value).then((uid) => { + this.messageUid = uid; + }); + } + + updateMessage() { + if (this.value[0] == "/") { + return false; + } + if (!this.messageUid) { + this.newMessage(); + return false; + } + if (this.messageUid === '?') { + return false; + } + if (typeof app !== "undefined" && app.rpc && typeof app.rpc.updateMessageText === "function") { + app.rpc.updateMessageText(this.messageUid, this.value); + } + } + + updateStatus() { + if (this.liveType) { + return; + } + if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) { + this.lastUpdateEvent = new Date(); + if (typeof app !== "undefined" && app.rpc && typeof app.rpc.set_typing === "function") { + app.rpc.set_typing(this.channelUid); + } + } + } + + update() { + const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval; + const changed = (this.value !== this.previousValue); + + if (changed || expired) { + this.lastChange = new Date(); + this.updateStatus(); + } + + this.previousValue = this.value; + + if (this.liveType && expired) { + this.value = ""; + this.previousValue = ""; + this.messageUid = null; + return; + } + + if (changed) { + if (this.liveType) { + this.updateMessage(); + } + } + } + + async sendMessage(channelUid, value) { + if (!value.trim()) { + return null; + } + return await app.rpc.sendMessage(channelUid, value); + } } -customElements.define('chat-input', ChatInputElement); +customElements.define('chat-input', ChatInputComponent); diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index 7baa67a..596b5d1 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -18,6 +18,7 @@ <script src="/file-manager.js" type="module"></script> <script src="/user-list.js"></script> <script src="/message-list.js" type="module"></script> + <script src="/chat-input.js" type="module"></script> <link rel="stylesheet" href="/user-list.css"> <link rel="stylesheet" href="/base.css"> diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 02e60df..94d0ac5 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -4,10 +4,6 @@ {% block main %} - - - - <section class="chat-area"> <message-list class="chat-messages"> {% for message in messages %} @@ -16,10 +12,7 @@ {% endautoescape %} {% endfor %} </message-list> - <div class="chat-input"> - <textarea list="chat-input-autocomplete-items" placeholder="Type a message..." rows="2" autocomplete="on"></textarea> - <upload-button channel="{{ channel.uid.value }}"></upload-button> - </div> + <chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input> </section> {% include "dialog_help.html" %} {% include "dialog_online.html" %} @@ -27,11 +20,8 @@ import { app } from "/app.js"; import { Schedule } from "/schedule.js"; const channelUid = "{{ channel.uid.value }}"; - - function getInputField(){ - return document.querySelector("textarea") - } - getInputField().autoComplete = { + const chatInputField = document.querySelector("chat-input"); + chatInputField.autoCompletions = { "/online": () =>{ showOnline(); }, @@ -39,117 +29,15 @@ document.querySelector(".chat-messages").innerHTML = ''; }, "/live": () =>{ - getInputField().liveType = !getInputField().liveType + + chatInputField.liveType = !chatInputField.liveType }, "/help": () => { showHelp(); } - } - - - function initInputField(textBox) { - if(textBox.liveType == undefined){ - textBox.liveType = false - } - let typeTimeout = null; - textBox.addEventListener('keydown',async (e) => { - if(typeTimeout){ - clearTimeout(typeTimeout) - typeTimeout = null - } - if(e.target.liveType){ - typeTimeout = setTimeout(()=>{ - e.target.lastMessageUid = null - e.target.value = '' - },3000) - } - if(e.key === "ArrowUp"){ - const value = findDivAboveText(e.target.value).querySelector('.text') - e.target.value = value.textContent - console.info("HIERR") - return - } - if (e.key === "Tab") { - - const message = e.target.value.trim(); - if (!message) { - return - } - let autoCompleteHandler = null; - Object.keys(e.target.autoComplete).forEach((key)=>{ - if(key.startsWith(message)){ - if(autoCompleteHandler){ - return - } - autoCompleteHandler = key - } - }) - if(autoCompleteHandler){ - e.preventDefault(); - e.target.value = autoCompleteHandler; - return - } - } - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - const message = e.target.value.trim(); - if (!message) { - return - } - let autoCompleteHandler = e.target.autoComplete[message] - if(autoCompleteHandler){ - const value = message; - e.target.value = ''; - autoCompleteHandler(value) - return - } - - e.target.value = ''; - if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){ - - - app.rpc.updateMessageText(textBox.lastMessageUid, message) - textBox.lastMessageUid = null - return - } - - const messageResponse = await app.rpc.sendMessage(channelUid, message); - - }else{ - if(textBox.liveType){ - if(e.target.value.endsWith("\n") || e.target.value.endsWith(" ")){ - return - } - if(e.target.value[0] == "/"){ - return - } - if(!textBox.lastMessageUid){ - textBox.lastMessageUid = '?' - app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{ - textBox.lastMessageUid = messageResponse - }) - } - if(textBox.lastMessageUid == '?'){ - return; - } - app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value) - }else{ - app.rpc.set_typing(channelUid) - } - - - } - }); - document.querySelector("upload-button").addEventListener("upload",function(e){ - getInputField().focus(); - }) - document.querySelector("upload-button").addEventListener("uploaded",function(e){ - let message = "" - e.detail.files.forEach((file)=>{ - message += `[${file.name}](/channel/attachment/${file.relative_url})` - }) - app.rpc.sendMessage(channelUid,message) - }) + } + + const textBox = document.querySelector("chat-input").textarea textBox.addEventListener("paste", async (e) => { try { const clipboardItems = await navigator.clipboard.read(); @@ -168,7 +56,7 @@ } if (dt.items.length > 0) { - const uploadButton = document.querySelector("upload-button"); + const uploadButton = chatInputField.uploadButton const input = uploadButton.shadowRoot.querySelector('.file-input') input.files = dt.files; @@ -187,7 +75,7 @@ const dt = e.dataTransfer; if (dt.items.length > 0) { - const uploadButton = document.querySelector("upload-button"); + const uploadButton = chatInputField.uploadButton const input = uploadButton.shadowRoot.querySelector('.file-input') input.files = dt.files; @@ -197,13 +85,16 @@ chatInput.addEventListener("dragover", async (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "link"; + + }) - textBox.focus(); - } + chatInputField.textarea.focus(); + + function replyMessage(message) { - const field = getInputField() + const field = chatInputField field.value = "```markdown\n> " + (message || '') + "\n```\n"; field.focus(); } @@ -294,8 +185,8 @@ lastMessage = messagesContainer.querySelector(".message:last-child"); if (doScrollDown) { lastMessage?.scrollIntoView({ block: "end", inline: "nearest" }); - const inputBox = document.querySelector(".chat-input"); - inputBox.scrollIntoView({ block: "end", inline: "nearest" }); + + chatInputField.scrollIntoView({ block: "end", inline: "nearest" }); } } @@ -378,17 +269,17 @@ messagesContainer.querySelector(".message:last-child").scrollIntoView({ block: "end", inline: "nearest" }); setTimeout(() => { - getInputField().focus(); + chatInputField.focus(); },500) } } if (event.shiftKey && event.key === 'G') { - if(document.activeElement != getInputField()){ + if(chatInputField.isActive()){ updateLayout(true); setTimeout(() => { - getInputField().focus(); + chatInputField.focus(); },500) } @@ -432,7 +323,6 @@ document.body.removeChild(overlay); }); }); - initInputField(getInputField()); updateLayout(true); </script> {% endblock %}