From dadce8f37337084239c9d7c8348733a4ee54e534 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Fri, 2 Feb 2024 06:44:02 -0500 Subject: [PATCH] chat and rename, half-working turn status. re-add crusty guac keyboard shit --- .parcelrc | 4 + package.json | 7 +- src/css/style.css | 20 ++- src/html/index.html | 2 +- src/js/keyboard.js | 282 ++++++++++++++++++++++++++++++ src/ts/main.ts | 101 +++++++++-- src/ts/protocol/CollabVMClient.ts | 46 ++++- src/ts/protocol/TurnStatus.ts | 12 ++ src/ts/protocol/User.ts | 5 +- static/.gitkeep | 0 10 files changed, 463 insertions(+), 16 deletions(-) create mode 100644 .parcelrc create mode 100644 src/js/keyboard.js create mode 100644 src/ts/protocol/TurnStatus.ts create mode 100644 static/.gitkeep diff --git a/.parcelrc b/.parcelrc new file mode 100644 index 0000000..55a9ca4 --- /dev/null +++ b/.parcelrc @@ -0,0 +1,4 @@ +{ + "extends": ["@parcel/config-default"], + "reporters": ["...", "parcel-reporter-static-files-copy"] +} \ No newline at end of file diff --git a/package.json b/package.json index 9a3462f..920b6fb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "private": true, "scripts": { "build": "parcel build --dist-dir dist src/html/index.html", - "serve": "parcel src/html/index.html" + "serve": "parcel src/html/index.html", + "clean": "run-script-os", + "clean:darwin:linux": "rm -rf dist .parcel-cache", + "clean:win32": "rd /s /q dist .parcel-cache" }, "author": "Elijah R", "license": "GPL-3.0", @@ -20,6 +23,8 @@ }, "devDependencies": { "parcel": "^2.11.0", + "parcel-reporter-static-files-copy": "^1.5.3", + "run-script-os": "^1.1.6", "typescript": "^5.3.3" } } diff --git a/src/css/style.css b/src/css/style.css index 6eb1b37..e2d944b 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -87,10 +87,26 @@ display: none; } -.user-admin { +tr.user-admin > td { color: #FF0000 !important; } -.user-moderator { +tr.user-moderator > td { color: #00FF00 !important; +} + +tr.user-turn > td { + background-color: #cfe2ff !important; +} + +tr.user-turn > td:hover, tr.user-turn > td:active { + background-color: #bacbe6 !important; +} + +tr.user-waiting > td { + background-color: #fff3cd !important; +} + +.tr.user-waiting > td:hover, .tr.user-waiting > td:active { + background-color: #ece1be; } \ No newline at end of file diff --git a/src/html/index.html b/src/html/index.html index a4539ec..df5cdd9 100644 --- a/src/html/index.html +++ b/src/html/index.html @@ -129,7 +129,7 @@
- + diff --git a/src/js/keyboard.js b/src/js/keyboard.js new file mode 100644 index 0000000..6425bc4 --- /dev/null +++ b/src/js/keyboard.js @@ -0,0 +1,282 @@ +// Pulled a bunch of functions out of the guac source code to get a keysym +// and then a wrapper +// shitty but it works so /shrug +// THIS SUCKS SO BAD AND I HATE IT PLEASE REWRITE ALL OF THIS + +export default function GetKeysym(keyCode, keyIdentifier, key, location) { + var keysym = keysym_from_key_identifier(key, location) + || keysym_from_keycode(keyCode, location); + + if (!keysym && key_identifier_sane(keyCode, keyIdentifier)) + keysym = keysym_from_key_identifier(keyIdentifier, location); + + return keysym; +} + + +function keysym_from_key_identifier(identifier, location) { + + if (!identifier) + return null; + + var typedCharacter; + + // If identifier is U+xxxx, decode Unicode character + var unicodePrefixLocation = identifier.indexOf("U+"); + if (unicodePrefixLocation >= 0) { + var hex = identifier.substring(unicodePrefixLocation+2); + typedCharacter = String.fromCharCode(parseInt(hex, 16)); + } + + // If single character, use that as typed character + else if (identifier.length === 1) + typedCharacter = identifier; + + // Otherwise, look up corresponding keysym + else + return get_keysym(keyidentifier_keysym[identifier], location); + + // Get codepoint + var codepoint = typedCharacter.charCodeAt(0); + return keysym_from_charcode(codepoint); + +} + +function get_keysym(keysyms, location) { + + if (!keysyms) + return null; + + return keysyms[location] || keysyms[0]; +} + +function keysym_from_charcode(codepoint) { + + // Keysyms for control characters + if (isControlCharacter(codepoint)) return 0xFF00 | codepoint; + + // Keysyms for ASCII chars + if (codepoint >= 0x0000 && codepoint <= 0x00FF) + return codepoint; + + // Keysyms for Unicode + if (codepoint >= 0x0100 && codepoint <= 0x10FFFF) + return 0x01000000 | codepoint; + + return null; +} + + +function isControlCharacter(codepoint) { + return codepoint <= 0x1F || (codepoint >= 0x7F && codepoint <= 0x9F); +} + +function keysym_from_keycode(keyCode, location) { + return get_keysym(keycodeKeysyms[keyCode], location); +} + +function key_identifier_sane(keyCode, keyIdentifier) { + + // Missing identifier is not sane + if (!keyIdentifier) + return false; + + // Assume non-Unicode keyIdentifier values are sane + var unicodePrefixLocation = keyIdentifier.indexOf("U+"); + if (unicodePrefixLocation === -1) + return true; + + // If the Unicode codepoint isn't identical to the keyCode, + // then the identifier is likely correct + var codepoint = parseInt(keyIdentifier.substring(unicodePrefixLocation+2), 16); + if (keyCode !== codepoint) + return true; + + // The keyCodes for A-Z and 0-9 are actually identical to their + // Unicode codepoints + if ((keyCode >= 65 && keyCode <= 90) || (keyCode >= 48 && keyCode <= 57)) + return true; + + // The keyIdentifier does NOT appear sane + return false; + +} + +var keycodeKeysyms = { + 8: [0xFF08], // backspace + 9: [0xFF09], // tab + 12: [0xFF0B, 0xFF0B, 0xFF0B, 0xFFB5], // clear / KP 5 + 13: [0xFF0D], // enter + 16: [0xFFE1, 0xFFE1, 0xFFE2], // shift + 17: [0xFFE3, 0xFFE3, 0xFFE4], // ctrl + 18: [0xFFE9, 0xFFE9, 0xFE03], // alt + 19: [0xFF13], // pause/break + 20: [0xFFE5], // caps lock + 27: [0xFF1B], // escape + 32: [0x0020], // space + 33: [0xFF55, 0xFF55, 0xFF55, 0xFFB9], // page up / KP 9 + 34: [0xFF56, 0xFF56, 0xFF56, 0xFFB3], // page down / KP 3 + 35: [0xFF57, 0xFF57, 0xFF57, 0xFFB1], // end / KP 1 + 36: [0xFF50, 0xFF50, 0xFF50, 0xFFB7], // home / KP 7 + 37: [0xFF51, 0xFF51, 0xFF51, 0xFFB4], // left arrow / KP 4 + 38: [0xFF52, 0xFF52, 0xFF52, 0xFFB8], // up arrow / KP 8 + 39: [0xFF53, 0xFF53, 0xFF53, 0xFFB6], // right arrow / KP 6 + 40: [0xFF54, 0xFF54, 0xFF54, 0xFFB2], // down arrow / KP 2 + 45: [0xFF63, 0xFF63, 0xFF63, 0xFFB0], // insert / KP 0 + 46: [0xFFFF, 0xFFFF, 0xFFFF, 0xFFAE], // delete / KP decimal + 91: [0xFFEB], // left window key (hyper_l) + 92: [0xFF67], // right window key (menu key?) + 93: null, // select key + 96: [0xFFB0], // KP 0 + 97: [0xFFB1], // KP 1 + 98: [0xFFB2], // KP 2 + 99: [0xFFB3], // KP 3 + 100: [0xFFB4], // KP 4 + 101: [0xFFB5], // KP 5 + 102: [0xFFB6], // KP 6 + 103: [0xFFB7], // KP 7 + 104: [0xFFB8], // KP 8 + 105: [0xFFB9], // KP 9 + 106: [0xFFAA], // KP multiply + 107: [0xFFAB], // KP add + 109: [0xFFAD], // KP subtract + 110: [0xFFAE], // KP decimal + 111: [0xFFAF], // KP divide + 112: [0xFFBE], // f1 + 113: [0xFFBF], // f2 + 114: [0xFFC0], // f3 + 115: [0xFFC1], // f4 + 116: [0xFFC2], // f5 + 117: [0xFFC3], // f6 + 118: [0xFFC4], // f7 + 119: [0xFFC5], // f8 + 120: [0xFFC6], // f9 + 121: [0xFFC7], // f10 + 122: [0xFFC8], // f11 + 123: [0xFFC9], // f12 + 144: [0xFF7F], // num lock + 145: [0xFF14], // scroll lock + 225: [0xFE03] // altgraph (iso_level3_shift) +}; + +var keyidentifier_keysym = { + "Again": [0xFF66], + "AllCandidates": [0xFF3D], + "Alphanumeric": [0xFF30], + "Alt": [0xFFE9, 0xFFE9, 0xFE03], + "Attn": [0xFD0E], + "AltGraph": [0xFE03], + "ArrowDown": [0xFF54], + "ArrowLeft": [0xFF51], + "ArrowRight": [0xFF53], + "ArrowUp": [0xFF52], + "Backspace": [0xFF08], + "CapsLock": [0xFFE5], + "Cancel": [0xFF69], + "Clear": [0xFF0B], + "Convert": [0xFF21], + "Copy": [0xFD15], + "Crsel": [0xFD1C], + "CrSel": [0xFD1C], + "CodeInput": [0xFF37], + "Compose": [0xFF20], + "Control": [0xFFE3, 0xFFE3, 0xFFE4], + "ContextMenu": [0xFF67], + "DeadGrave": [0xFE50], + "DeadAcute": [0xFE51], + "DeadCircumflex": [0xFE52], + "DeadTilde": [0xFE53], + "DeadMacron": [0xFE54], + "DeadBreve": [0xFE55], + "DeadAboveDot": [0xFE56], + "DeadUmlaut": [0xFE57], + "DeadAboveRing": [0xFE58], + "DeadDoubleacute": [0xFE59], + "DeadCaron": [0xFE5A], + "DeadCedilla": [0xFE5B], + "DeadOgonek": [0xFE5C], + "DeadIota": [0xFE5D], + "DeadVoicedSound": [0xFE5E], + "DeadSemivoicedSound": [0xFE5F], + "Delete": [0xFFFF], + "Down": [0xFF54], + "End": [0xFF57], + "Enter": [0xFF0D], + "EraseEof": [0xFD06], + "Escape": [0xFF1B], + "Execute": [0xFF62], + "Exsel": [0xFD1D], + "ExSel": [0xFD1D], + "F1": [0xFFBE], + "F2": [0xFFBF], + "F3": [0xFFC0], + "F4": [0xFFC1], + "F5": [0xFFC2], + "F6": [0xFFC3], + "F7": [0xFFC4], + "F8": [0xFFC5], + "F9": [0xFFC6], + "F10": [0xFFC7], + "F11": [0xFFC8], + "F12": [0xFFC9], + "F13": [0xFFCA], + "F14": [0xFFCB], + "F15": [0xFFCC], + "F16": [0xFFCD], + "F17": [0xFFCE], + "F18": [0xFFCF], + "F19": [0xFFD0], + "F20": [0xFFD1], + "F21": [0xFFD2], + "F22": [0xFFD3], + "F23": [0xFFD4], + "F24": [0xFFD5], + "Find": [0xFF68], + "GroupFirst": [0xFE0C], + "GroupLast": [0xFE0E], + "GroupNext": [0xFE08], + "GroupPrevious": [0xFE0A], + "FullWidth": null, + "HalfWidth": null, + "HangulMode": [0xFF31], + "Hankaku": [0xFF29], + "HanjaMode": [0xFF34], + "Help": [0xFF6A], + "Hiragana": [0xFF25], + "HiraganaKatakana": [0xFF27], + "Home": [0xFF50], + "Hyper": [0xFFED, 0xFFED, 0xFFEE], + "Insert": [0xFF63], + "JapaneseHiragana": [0xFF25], + "JapaneseKatakana": [0xFF26], + "JapaneseRomaji": [0xFF24], + "JunjaMode": [0xFF38], + "KanaMode": [0xFF2D], + "KanjiMode": [0xFF21], + "Katakana": [0xFF26], + "Left": [0xFF51], + "Meta": [0xFFE7, 0xFFE7, 0xFFE8], + "ModeChange": [0xFF7E], + "NumLock": [0xFF7F], + "PageDown": [0xFF56], + "PageUp": [0xFF55], + "Pause": [0xFF13], + "Play": [0xFD16], + "PreviousCandidate": [0xFF3E], + "PrintScreen": [0xFD1D], + "Redo": [0xFF66], + "Right": [0xFF53], + "RomanCharacters": null, + "Scroll": [0xFF14], + "Select": [0xFF60], + "Separator": [0xFFAC], + "Shift": [0xFFE1, 0xFFE1, 0xFFE2], + "SingleCandidate": [0xFF3C], + "Super": [0xFFEB, 0xFFEB, 0xFFEC], + "Tab": [0xFF09], + "Up": [0xFF52], + "Undo": [0xFF65], + "Win": [0xFFEB], + "Zenkaku": [0xFF28], + "ZenkakuHankaku": [0xFF2A] +}; \ No newline at end of file diff --git a/src/ts/main.ts b/src/ts/main.ts index 57ffaf2..8a21656 100644 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -3,6 +3,7 @@ import VM from "./protocol/VM.js"; import { Config } from "../../Config.js"; import { Rank } from "./protocol/Permissions.js"; import { User } from "./protocol/User.js"; +import TurnStatus from "./protocol/TurnStatus.js"; // Elements const w = window as any; @@ -16,7 +17,14 @@ const elements = { userlist: document.getElementById('userlist') as HTMLTableSectionElement, onlineusercount: document.getElementById("onlineusercount") as HTMLSpanElement, username: document.getElementById("username") as HTMLSpanElement, + chatinput: document.getElementById("chat-input") as HTMLInputElement, + sendChatBtn: document.getElementById("sendChatBtn") as HTMLButtonElement, + changeUsernameBtn: document.getElementById("changeUsernameBtn") as HTMLButtonElement, + turnBtnText: document.getElementById("turnBtnText") as HTMLSpanElement, + turnstatus: document.getElementById("turnstatus") as HTMLParagraphElement, } +var expectedClose = false; +var turn = -1; // Listed VMs const vms : VM[] = []; const cards : HTMLDivElement[] = []; @@ -61,22 +69,31 @@ function openVM(vm : VM) { return new Promise(async (res, rej) => { // If there's an active VM it must be closed before opening another if (VM !== null) return; + expectedClose = false; // Set hash location.hash = vm.id; // Create the client VM = new CollabVMClient(vm.url); // Register event listeners - VM!.on('chat', (username, message) => chatMessage(username, message)); - VM!.on('adduser', (user) => addUser(user)); - VM!.on('remuser', (user) => remUser(user)); - VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)); - VM!.on('renamestatus', (status) => { + // An array to keep track of all listeners, and remove them when the VM is closed. Might not be necessary, but it's good practice. + var listeners : (() => void)[] = []; + listeners.push(VM!.on('chat', (username, message) => chatMessage(username, message))); + listeners.push(VM!.on('adduser', (user) => addUser(user))); + listeners.push(VM!.on('remuser', (user) => remUser(user))); + listeners.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename))); + listeners.push(VM!.on('renamestatus', (status) => { switch (status) { case 'taken': alert("That username is already taken"); break; case 'invalid': alert("Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters."); break; case 'blacklisted': alert("That username has been blacklisted."); break; } - }); + })); + listeners.push(VM!.on('turn', status => turnUpdate(status))); + listeners.push(VM!.on('close', () => { + if (!expectedClose) alert("You have been disconnected from the server"); + for (var l of listeners) l(); + closeVM(); + })); // Wait for the client to open await new Promise(res => VM!.on('open', () => res())); // Connect to node @@ -99,6 +116,7 @@ function openVM(vm : VM) { function closeVM() { if (VM === null) return; + expectedClose = true; // Close the VM VM.close(); VM = null; @@ -118,7 +136,7 @@ function loadList() { p.push(multicollab(url)); } await Promise.all(p); - var v = vms.find(v => v.id === window.location.hash.slice(1)); + var v = vms.find(v => v.id === window.location.hash.substring(1)); if (v !== undefined) openVM(v); res(); }); @@ -132,6 +150,21 @@ function sortVMList() { cards.forEach((c) => elements.vmlist.children[0].appendChild(c)); } +function sortUserList() { + const users = Array.prototype.slice.call(elements.userlist.children); + users.sort((a, b) => { + if (parseInt(a.getAttribute("data-cvm-turn")) === parseInt(b.getAttribute("data-cvm-turn"))) return 0; + if (parseInt(a.getAttribute("data-cvm-turn")) === -1) return 1; + if (parseInt(b.getAttribute("data-cvm-turn")) === -1) return -1; + if (parseInt(a.getAttribute("data-cvm-turn")) < parseInt(b.getAttribute("data-cvm-turn"))) return -1; + else return 1; + }); + for (const user of users) { + elements.userlist.removeChild(user); + elements.userlist.appendChild(user); + } +} + function chatMessage(username : string, message : string) { var tr = document.createElement('tr'); var td = document.createElement('td'); @@ -176,17 +209,18 @@ function addUser(user : User) { var olduser = Array.prototype.slice.call(elements.userlist.children).find((u : HTMLTableRowElement) => u.children[0].innerHTML === user.username); if (olduser !== undefined) elements.userlist.removeChild(olduser); var tr = document.createElement('tr'); + tr.setAttribute("data-cvm-turn", "-1"); var td = document.createElement('td'); td.innerHTML = user.username; switch (user.rank) { case Rank.Admin: - td.classList.add("user-admin"); + tr.classList.add("user-admin"); break; case Rank.Moderator: - td.classList.add("user-moderator"); + tr.classList.add("user-moderator"); break; case Rank.Unregistered: - td.classList.add("user-unregistered"); + tr.classList.add("user-unregistered"); break; } tr.appendChild(td); @@ -212,9 +246,56 @@ function userRenamed(oldname : string, newname : string, selfrename : boolean) { } } +function turnUpdate(status : TurnStatus) { + const users = Array.prototype.slice.call(elements.userlist.children); + // Clear all turn data + turn = -1; + for (const user of users) { + user.classList.remove("user-turn", "user-waiting"); + user.setAttribute("data-cvm-turn", "-1"); + } + elements.turnBtnText.innerHTML = "Take Turn"; + if (status.user !== null) { + var el = users.find((e : HTMLTableRowElement) => e.children[0].innerHTML === status.user!.username); + el!.classList.add("user-turn"); + el!.setAttribute("data-cvm-turn", "0"); + } + for (const user of status.queue) { + var el = users.find((e : HTMLTableRowElement) => e.children[0].innerHTML === user.username); + el!.classList.add("user-waiting"); + el.setAttribute("data-cvm-turn", status.queue.indexOf(user)) + } + if (status.user?.username === w.username) { + turn = 0; + elements.turnBtnText.innerHTML = "End Turn"; + } + if (status.queue.some(u => u.username === w.username)) { + turn = status.queue.findIndex(u => u.username === w.username) + 1; + elements.turnBtnText.innerHTML = "End Turn"; + } + sortUserList(); +} + +function sendChat() { + if (VM === null) return; + VM.chat(elements.chatinput.value); + elements.chatinput.value = ""; +} + // Bind list buttons elements.homeBtn.addEventListener('click', () => closeVM()); +// Bind VM view buttons +elements.sendChatBtn.addEventListener('click', sendChat); +elements.chatinput.addEventListener('keypress', (e) => { + if (e.key === "Enter") sendChat(); +}); +elements.changeUsernameBtn.addEventListener('click', () => { + var newname = prompt("Enter new username, or leave blank to be assigned a guest username", w.username); + if (newname === w.username) return; + VM?.rename(newname); +}) + // Public API w.collabvm = { openVM: openVM, diff --git a/src/ts/protocol/CollabVMClient.ts b/src/ts/protocol/CollabVMClient.ts index 195b930..fdacc0d 100644 --- a/src/ts/protocol/CollabVMClient.ts +++ b/src/ts/protocol/CollabVMClient.ts @@ -3,6 +3,7 @@ import * as Guacutils from './Guacutils.js'; import VM from "./VM.js"; import { User } from "./User.js"; import { Rank } from "./Permissions.js"; +import TurnStatus from "./TurnStatus.js"; export default class CollabVMClient { // Fields @@ -35,6 +36,7 @@ export default class CollabVMClient { // Add the event listeners this.socket.addEventListener('open', () => this.onOpen()); this.socket.addEventListener('message', (event) => this.onMessage(event)); + this.socket.addEventListener('close', () => this.publicEmitter.emit('close')); } // Fires when the WebSocket connection is opened @@ -138,6 +140,37 @@ export default class CollabVMClient { this.publicEmitter.emit('rename', oldusername, msgArr[3], selfrename); break; } + case "turn": { + // Reset all turn data + for (var user of this.users) user.turn = -1; + var queuedUsers = parseInt(msgArr[2]); + if (queuedUsers === 0) { + this.publicEmitter.emit('turn', { + user: null, + queue: [], + turnTime: null, + queueTime: null, + } as TurnStatus); + return; + } + var currentTurn = this.users.find(u => u.username === msgArr[3])!; + currentTurn.turn = 0; + var queue : User[] = []; + if (queuedUsers > 1) { + for (var i = 1; i < queuedUsers; i++) { + var user = this.users.find(u => u.username === msgArr[i+3])!; + queue.push(user); + user.turn = i; + } + } + this.publicEmitter.emit('turn', { + user: currentTurn, + queue: queue, + turnTime: currentTurn.username === this.username ? parseInt(msgArr[1]) : null, + queueTime: queue.some(u => u.username === this.username) ? parseInt(msgArr[msgArr.length - 1]) : null, + } as TurnStatus) + break; + } } } @@ -185,7 +218,7 @@ export default class CollabVMClient { // Close the connection close() { this.connectedToVM = false; - this.socket.close(); + if (this.socket.readyState === WebSocket.OPEN) this.socket.close(); } // Get users @@ -194,5 +227,16 @@ export default class CollabVMClient { return this.users.slice(); } + // Send a chat message + chat(message : string) { + this.send("chat", message); + } + + // Rename + rename(username : string | null = null) { + if (username) this.send("rename", username); + else this.send("rename"); + } + on = (event : string | number, cb: (...args: any) => void) => this.publicEmitter.on(event, cb); } \ No newline at end of file diff --git a/src/ts/protocol/TurnStatus.ts b/src/ts/protocol/TurnStatus.ts new file mode 100644 index 0000000..8720cba --- /dev/null +++ b/src/ts/protocol/TurnStatus.ts @@ -0,0 +1,12 @@ +import { User } from "./User.js"; + +export default interface TurnStatus { + // The user currently taking their turn + user : User | null; + // The users in the turn queue + queue : User[]; + // Amount of time left in the turn. Null unless the user is taking their turn + turnTime : number | null; + // Amount of time until the user gets their turn. Null unless the user is in the queue + queueTime : number | null; +} \ No newline at end of file diff --git a/src/ts/protocol/User.ts b/src/ts/protocol/User.ts index 2faaabc..c7942a9 100644 --- a/src/ts/protocol/User.ts +++ b/src/ts/protocol/User.ts @@ -2,10 +2,13 @@ import { Rank } from "./Permissions.js"; export class User { username : string; - rank : Rank + rank : Rank; + // -1 means not in the turn queue, 0 means the current turn, anything else is the position in the queue + turn : number; constructor(username : string, rank : Rank = Rank.Unregistered) { this.username = username; this.rank = rank; + this.turn = -1; } } \ No newline at end of file diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29