From e81a4c0973f6a17b4f2feff33b791ab470ee8cca Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Wed, 1 Oct 2025 04:33:24 -0400 Subject: [PATCH] Basic chat UI complete. --- src/app/pm/pmHandler.js | 11 +- src/views/partial/panels/pm.ejs | 8 +- www/css/panel/pm.css | 24 ++ www/css/popup/startChatSesh.css | 27 ++ www/css/theme/movie-night.css | 12 +- www/js/channel/panels/emotePanel.js | 3 + www/js/channel/panels/pmPanel.js | 246 +++++++++++++++++- .../channel/panels/queuePanel/queuePanel.js | 2 +- www/js/channel/panels/settingsPanel.js | 3 + www/js/channel/pmHandler.js | 105 ++++---- www/popup/startChatSesh.html | 23 ++ 11 files changed, 393 insertions(+), 71 deletions(-) create mode 100644 www/css/popup/startChatSesh.css create mode 100644 www/popup/startChatSesh.html diff --git a/src/app/pm/pmHandler.js b/src/app/pm/pmHandler.js index cf8daab..0014ec2 100644 --- a/src/app/pm/pmHandler.js +++ b/src/app/pm/pmHandler.js @@ -150,17 +150,14 @@ class pmHandler{ * @returns {String} sanatized/validates message, returns null on validation failure */ sanatizeMessage(msg){ - //if msg is empty or null - if(msg == null || msg == ''){ - //Pimp slap that shit into fucking oblivion - return null; - } + //Normally I'd kill empty messages here + //But instead we're allowing them for sesh startups //Trim and Sanatize for XSS msg = validator.trim(validator.escape(msg)); - //Return whether or not the shit was long enough - if(validator.isLength(msg, {min: 1, max: 255})){ + //Return whether or not the shit was too long + if(validator.isLength(msg, {min: 0, max: 255})){ //If it's valid return the message return msg; } diff --git a/src/views/partial/panels/pm.ejs b/src/views/partial/panels/pm.ejs index af3e372..958a18c 100644 --- a/src/views/partial/panels/pm.ejs +++ b/src/views/partial/panels/pm.ejs @@ -26,11 +26,13 @@ along with this program. If not, see . %>
- +
+

Start a sesh to start chatting!

+
- - + +
diff --git a/www/css/panel/pm.css b/www/css/panel/pm.css index df2db42..2e6663c 100644 --- a/www/css/panel/pm.css +++ b/www/css/panel/pm.css @@ -61,8 +61,32 @@ div.pm-panel-sesh-list-entry{ flex-direction: row; } + +div.pm-panel-sesh-list-entry p{ + pointer-events: none; +} + div.pm-panel-sesh-list-entry, div.pm-panel-sesh-list-entry p{ margin: 0; text-wrap: nowrap; text-align: center; +} + +#pm-panel-sesh-buffer span{ + display: flex; + flex-direction: row; + margin: 0; +} + +.pm-panel-sesh-message-sender, .pm-panel-sesh-message-content{ + margin: 0; + font-size: 10pt; +} + +#pm-panel-sesh-welcome{ + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + height: 100%; } \ No newline at end of file diff --git a/www/css/popup/startChatSesh.css b/www/css/popup/startChatSesh.css new file mode 100644 index 0000000..86cd92f --- /dev/null +++ b/www/css/popup/startChatSesh.css @@ -0,0 +1,27 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2025 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ +#pm-sesh-popup-div{ + display: flex; +} + + +#pm-sesh-popup-div p{ + margin: 0; +} + +#pm-sesh-popup-sup{ + font-size: 0.7em +} \ No newline at end of file diff --git a/www/css/theme/movie-night.css b/www/css/theme/movie-night.css index 96e5793..00e2f24 100644 --- a/www/css/theme/movie-night.css +++ b/www/css/theme/movie-night.css @@ -129,13 +129,13 @@ button{ border-radius: 0.5em; } -button:hover{ +button:hover:not([disabled]){ color: var(--focus0-alt1); background-color: var(--focus0-alt0); box-shadow: var(--focus-glow0); } -button:active{ +button:active:not([disabled]){ color: var(--focus0-alt0); background-color: var(--focus0-alt1); box-shadow: var(--focus-glow0-alt0); @@ -179,13 +179,13 @@ textarea{ color: var(--accent1); } -.danger-button:hover, .critical-danger-button, .critical-danger-button:hover{ +.danger-button:hover:not([disabled]), .critical-danger-button, .critical-danger-button:hover{ background-color: var(--danger0-alt1); color: var(--danger0-alt0); box-shadow: var(--danger-glow0); } -.critical-danger-button:hover{ +.critical-danger-button:hover:not([disabled]){ background-color: var(--danger0-alt2); } @@ -219,12 +219,12 @@ textarea{ color: white; } -.positive-button:hover{ +.positive-button:hover:not([disabled]){ color: var(--focus0-alt1); background-color: var(--focus0-alt0); } -.positive-button:active{ +.positive-button:active:not([disabled]){ color: var(--focus0-alt0); background-color: var(--focus0-alt1); } diff --git a/www/js/channel/panels/emotePanel.js b/www/js/channel/panels/emotePanel.js index 877b4f7..ee19936 100644 --- a/www/js/channel/panels/emotePanel.js +++ b/www/js/channel/panels/emotePanel.js @@ -64,6 +64,9 @@ class emotePanel extends panelObj{ this.setupInput(); this.renderEmoteLists(); + + //Call derived method + super.docSwitch(); } /** diff --git a/www/js/channel/panels/pmPanel.js b/www/js/channel/panels/pmPanel.js index e502f4f..85e191e 100644 --- a/www/js/channel/panels/pmPanel.js +++ b/www/js/channel/panels/pmPanel.js @@ -27,6 +27,11 @@ class pmPanel extends panelObj{ constructor(client, panelDocument){ super(client, "Private Messaging", "/panel/pm", panelDocument); + /** + * String to hold name of currently active sesh + */ + this.activeSesh = ""; + this.defineListeners(); } @@ -34,33 +39,112 @@ class pmPanel extends panelObj{ } docSwitch(){ + this.startSeshButton = this.panelDocument.querySelector('#pm-panel-start-sesh'); this.seshList = this.panelDocument.querySelector('#pm-panel-sesh-list'); + this.seshBuffer = this.panelDocument.querySelector('#pm-panel-sesh-buffer'); + this.seshPrompt = this.panelDocument.querySelector('#pm-panel-message-prompt'); + this.seshSendButton = this.panelDocument.querySelector('#pm-panel-send-button'); this.setupInput(); this.renderSeshList(); + + //If we have an active sesh + if(this.activeSesh != null && this.activeSesh != ""){ + //Render messages + this.renderMessages(); + } + + //Call derived method + super.docSwitch(); } /** * Defines network related event listeners */ defineListeners(){ - + this.client.pmSocket.on("message", this.handlePM.bind(this)); + this.client.pmSocket.on("sent", this.handlePM.bind(this)); } /** * Defines input-related event handlers */ setupInput(){ + this.startSeshButton.addEventListener('click', this.startSesh.bind(this)); + this.seshPrompt.addEventListener("keydown", this.send.bind(this)); + this.seshSendButton.addEventListener("click", this.send.bind(this)); + } + + startSesh(event){ + new startSeshPopup(event, this.client, this.renderSeshList.bind(this), this.ownerDoc); + } + + handlePM(data){ + const nameObj = pmHandler.genSeshName(data); + + //If this message is for the active sesh + if(nameObj.name == this.activeSesh){ + //Render out the newest message + this.renderMessage(data); + }else{ + //pull current session entry if it exists + const curEntry = this.panelDocument.querySelector(`[data-id="${nameObj.name}"]`); + + //If it doesn't exist + if(curEntry == null){ + //Re-render out the sesh list + this.renderSeshList(); + } + } + } + + /** + * sends private message from sesh prompt to server + * @param {Event} event - Event passed down from Event Handler + */ + send(event){ + if((!event || !event.key || event.key == "Enter") && this.seshPrompt.value && this.activeSesh != ''){ + //Pull current sesh from sesh list + const sesh = this.client.pmHandler.seshList.get(this.activeSesh); + + //Send message out to server + this.client.pmSocket.emit("pm", { + recipients: sesh.recipients, + msg: this.seshPrompt.value + }); + + //Clear our prompt + this.seshPrompt.value = ""; + } } /** * Render out current sesh array to sesh list UI */ renderSeshList(){ + //Clear out the sesh list + this.seshList.innerHTML = ""; + + //Assemble temporary array from client PM Handler sesh list + const seshList = Array.from(this.client.pmHandler.seshList); + + //If we have existing sessions and no active sesh + if(this.activeSesh == "" && seshList[0] != null){ + //Enable UI elements + this.seshPrompt.disabled = false; + this.seshSendButton.disabled = false; + + //Render out messages + this.renderMessages(); + + //Set the first one as active + this.activeSesh = seshList[0][1].id; + } + //For each session tracked by the pmHandler - for(const sesh of this.client.pmHandler.seshList){ - this.renderSeshListEntry(sesh); + for(const seshEntry of seshList){ + this.renderSeshListEntry(seshEntry[1]); } } @@ -72,17 +156,171 @@ class pmPanel extends panelObj{ const entryDiv = document.createElement('div'); //Set conatiner div classes entryDiv.classList.add('pm-panel-sesh-list-entry','interactive'); + //Set dataset sesh name + entryDiv.dataset.id = sesh.id; + //If the current entry is the active sesh + if(sesh.id == this.activeSesh){ + //mark it as such + entryDiv.classList.add('positive'); + } //Create sesh label const seshLabel = document.createElement('p'); //Create human-readable label out of members array - seshLabel.textContent = utils.unescapeEntities(sesh.recipients.sort().join(', ')); + seshLabel.textContent = utils.unescapeEntities(sesh.id); //append sesh label to entry div entryDiv.appendChild(seshLabel); //Append entry div to sesh list this.seshList.appendChild(entryDiv); + + //Add input-related event listener for entry div + entryDiv.addEventListener('click', this.selectSesh.bind(this)); + } + + selectSesh(event){ + //Attempt to pull previously active sesh item from list + const wasActive = this.panelDocument.querySelector('.positive'); + + //If there was an active sesh + if(wasActive != null){ + //Remove active sesh class from old item + wasActive.classList.remove('positive'); + } + + //Pull new active sesh name from target dataset + this.activeSesh = event.target.dataset.id; + + //Set new sesh as active sesh + event.target.classList.add('positive'); + + //Re-render message buffer + this.renderMessages(); + } + + renderMessages(){ + //Empty out the sesh buffer + this.seshBuffer.innerHTML = ""; + + //Pull sesh from pmHandler + const sesh = this.client.pmHandler.seshList.get(this.activeSesh); + + //If the sesh is real + if(sesh != null){ + //for each message in the sesh + for(const message of sesh.messages){ + //Render out messages to the buffer + this.renderMessage(message); + } + } + } + + renderMessage(message){ + const msgSpan = document.createElement('span'); + + const msgSender = document.createElement('p'); + msgSender.innerText = utils.unescapeEntities(`${message.sender}:`); + msgSender.classList.add('pm-panel-sesh-message-sender'); + + const msgContent = document.createElement('p'); + msgContent.innerText = utils.unescapeEntities(message.msg); + msgContent.classList.add('pm-panel-sesh-message-content'); + + msgSpan.appendChild(msgSender); + msgSpan.appendChild(msgContent); + + this.seshBuffer.appendChild(msgSpan); + } +} + +/** + * Class representing pop-up dialogue to start a private messaging sesh + */ +class startSeshPopup{ + /** + * Instantiates a new schedule media Pop-up + * @param {Event} event - Event passed down from Event Listener + * @param {channel} client - Parent Client Management Object + * @param {String} url - URL/link to media to queue + * @param {String} title - Title of media to queue + * @param {Function} cb - Callback function, passed upon pop-up creation + * @param {Document} doc - Current owner documnet of the panel, so we know where to drop our pop-up + */ + constructor(event, client, cb, doc){ + /** + * Parent Client Management Object + */ + this.client = client; + + /** + * Callback function, passed upon pop-up creation + */ + this.cb = cb; + + //Create media popup and call async constructor when done + //unfortunately we cant call constructors asyncronously, and we cant call back to this from super, so we can't extend this as it stands :( + /** + * canopyUXUtils.popup() object + */ + this.popup = new canopyUXUtils.popup('/startChatSesh', true, this.asyncConstructor.bind(this), doc); + } + + /** + * Continuation of object construction, called after child popup object construction + */ + asyncConstructor(){ + //Grab required UI elements + this.startSeshButton = this.popup.contentDiv.querySelector('#pm-sesh-popup-button'); + this.usernamePrompt = this.popup.contentDiv.querySelector('#pm-sesh-popup-prompt'); + + //Setup input + this.setupInput(); + } + + /** + * Defines input-related Event Handlers + */ + setupInput(){ + //Setup input + this.startSeshButton.addEventListener('click', this.startSesh.bind(this)); + this.popup.popupDiv.addEventListener('keydown', this.startSesh.bind(this)); + } + + /** + * Handles sending request to schedule item to the queue + * @param {Event} event - Event passed down from Event Listener + */ + startSesh(event){ + //If we clicked or hit enter + if(event.key == null || event.key == "Enter"){ + /* + //Cook a new sesh from + const newSesh = new pmSesh({ + //Split usernames by space + sender: this.client.user.user, + recipients: this.usernamePrompt.value.split(" ") + }); + + //Pop new sesh into pmHandler + this.client.pmHandler.seshList.set(newSesh.id, newSesh); + */ + + //Send message out to server + this.client.pmSocket.emit("pm", { + recipients: this.usernamePrompt.value.split(" "), + msg: "" + }); + + //If we have a function + if(typeof this.cb == "function"){ + //Call any callbacks we where given + this.cb(); + } + + //Close the popup + this.popup.closePopup(); + } } } \ No newline at end of file diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 5fb159e..2b05766 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -1542,7 +1542,7 @@ class reschedulePopup extends schedulePopup{ this.media = media; } - schedule(event){ + startSesh(event){ //If we clicked or hit enter if(event.key == null || event.key == "Enter"){ //Get localized input date diff --git a/www/js/channel/panels/settingsPanel.js b/www/js/channel/panels/settingsPanel.js index 7f6b707..976afee 100644 --- a/www/js/channel/panels/settingsPanel.js +++ b/www/js/channel/panels/settingsPanel.js @@ -64,6 +64,9 @@ class settingsPanel extends panelObj{ this.renderSettings(); this.setupInput(); + + //Call derived method + super.docSwitch(); } /** diff --git a/www/js/channel/pmHandler.js b/www/js/channel/pmHandler.js index 09e1a3a..c3562f5 100644 --- a/www/js/channel/pmHandler.js +++ b/www/js/channel/pmHandler.js @@ -36,7 +36,7 @@ class pmHandler{ /** * List of PM Sessions */ - this.seshList = []; + this.seshList = new Map(); this.defineListeners(); this.setupInput(); @@ -66,50 +66,63 @@ class pmHandler{ //Store whether or not current message has been consumed by an existing sesh let consumed = false; + const nameObj = pmHandler.genSeshName(data); + //Create members array from scratch to avoid changing the input data for further processing - const members = []; - - //Manually iterate through recipients - for(const member of data.recipients){ - //check to make sure we're not adding ourselves - if(member != this.client.user.user){ - //Copy relevant array members by value instead of reference - members.push(member); - } - } - - //If this wasn't our message - if(data.sender != this.client.user.user){ - //Push sender onto members list - members.push(data.sender); - } + const members = nameObj.recipients; //For each existing sesh - for(let seshIndex in this.seshList){ - //Get current sesh - const sesh = this.seshList[seshIndex]; + for(const seshEntry of this.seshList){ + //Pull sesh object from map entry + const sesh = seshEntry[1]; - //Check to see if the length of sesh recipients equals current length (only check on arrays that actually make sense to save time) - if(sesh.recipients.length == members.length){ - /*Feels like cheating to have the JS engine to the hard bits by just telling it to sort them. - That being said, since the function is implemented into the JS Engine itself - It will be quicker than any custom comparison code we can write*/ + //If currently checked sesh ID matches calculated message sesh id + if(sesh.id == nameObj.name){ + //Dump collected message into the matching session + sesh.messages.push(data); - //Sort recipient lists so lists with the same user will be equal when joined together in a string and compare, if they're the same... - if(sesh.recipients.sort().join() == members.sort().join()){ - //Dump collected message into the matching session - this.seshList[seshIndex].messages.push(data); + //Add sesh to sesh map + this.seshList.set(sesh.id, sesh); - //Let the rest of the method know that we've consumed this message - consumed = true; - } + //Let the rest of the method know that we've consumed this message + consumed = true; } } //If we made it through the loop without consuming the message if(!consumed){ - //Add it to it's own fresh new sesh - this.seshList.push(new pmSesh(data, client)); + //Generate a new sesh + const sesh = new pmSesh(data, client); + + //Add it to the sesh list + this.seshList.set(sesh.id, sesh); + } + } + + static genSeshName(message){ + const recipients = []; + + //Manually iterate through recipients + for(const member of message.recipients){ + //check to make sure we're not adding ourselves + if(member != client.user.user){ + //Copy relevant array members by value instead of reference + recipients.push(member); + } + } + + //If this wasn't our message + if(message.sender != client.user.user){ + //Push sender onto members list + recipients.push(message.sender); + } + + //Sort recipients + recipients.sort(); + + return { + name: recipients.join(', '), + recipients } } } @@ -128,29 +141,21 @@ class pmSesh{ */ this.client = client; + const nameObj = pmHandler.genSeshName(message); + /** * Members of session excluding the currently logged in user */ - this.recipients = []; + this.recipients = nameObj.recipients - //Manually iterate through recipients - for(const member of message.recipients){ - //check to make sure we're not adding ourselves - if(member != this.client.user.user){ - //Copy relevant array members by value instead of reference - this.recipients.push(member); - } - } - - //If this wasn't our message - if(message.sender != this.client.user.user){ - //Push sender onto members list - this.recipients.push(message.sender); - } + /** + * Name of the chat sesh, named after out-going recipients + */ + this.id = nameObj.name; /** * Array containing all session messages */ - this.messages = [message]; + this.messages = (message.msg == "" || message.msg == null) ? [] : [message]; } } \ No newline at end of file diff --git a/www/popup/startChatSesh.html b/www/popup/startChatSesh.html new file mode 100644 index 0000000..b8bb3cc --- /dev/null +++ b/www/popup/startChatSesh.html @@ -0,0 +1,23 @@ + + + +
+

Enter user(s) to chat with:

+ + +
+Users must be online and connected to a channel (it doesn't have to be the same one.) \ No newline at end of file