Added code.
This commit is contained in:
1275
client/package-lock.json
generated
Normal file
1275
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
client/package.json
Normal file
23
client/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "coms-client",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js --watch public --ext js,html,css",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jacob Signorovitch",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"connect-livereload": "^0.6.1",
|
||||
"livereload": "^0.10.3",
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
148
client/public/app.js
Normal file
148
client/public/app.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
setReplyTo,
|
||||
getThreads,
|
||||
addNotice,
|
||||
updateUser,
|
||||
getGroupedUsers,
|
||||
getMyId,
|
||||
getUsers,
|
||||
getFocused,
|
||||
setFocused,
|
||||
clearFocused,
|
||||
} from "./data.js";
|
||||
import { renderChat } from "./render.js";
|
||||
import { initWebSocket } from "./wsModule.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const chat = document.getElementById("chat");
|
||||
const inputContainer = document.getElementById("inputContainer");
|
||||
const rootButton = document.getElementById("rootButton");
|
||||
const sendButton = document.getElementById("sendButton");
|
||||
const input = document.getElementById("messageInput");
|
||||
const usersList = document.getElementById("userList");
|
||||
const nameInput = document.getElementById("nameInput");
|
||||
const nameButton = document.getElementById("nameButton");
|
||||
|
||||
chat.style.display = "";
|
||||
inputContainer.style.display = "flex";
|
||||
|
||||
const defaultName = "Anon";
|
||||
const MAX_NAME_LEN = 31;
|
||||
|
||||
const { socket, sendMessage, sendName } = initWebSocket(
|
||||
defaultName,
|
||||
chat,
|
||||
inputContainer,
|
||||
usersList,
|
||||
);
|
||||
|
||||
// Renames.
|
||||
nameButton.addEventListener("click", () => {
|
||||
const newName = nameInput.value.trim() || defaultName;
|
||||
if (newName.length > MAX_NAME_LEN) {
|
||||
addNotice(
|
||||
`<span class="err">Name may be at most ${MAX_NAME_LEN} characters.</span>`,
|
||||
);
|
||||
renderChat(chat, inputContainer);
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
sendName(newName);
|
||||
updateUser(getMyId(), newName);
|
||||
const groups = getGroupedUsers();
|
||||
usersList.innerHTML = "Online: " + groups.join(", ");
|
||||
|
||||
nameInput.value = "";
|
||||
nameInput.placeholder = `Name: ${newName}`;
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Enter to set name.
|
||||
nameInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") nameButton.click();
|
||||
});
|
||||
|
||||
// Button to send.
|
||||
sendButton.addEventListener("click", () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
sendMessage(text);
|
||||
input.value = "";
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Enter to send and escape to go to root.
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
sendButton.click();
|
||||
} else if (e.key === "Escape") {
|
||||
setReplyTo(null);
|
||||
input.placeholder = "";
|
||||
renderChat(chat, inputContainer);
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Click message to reply.
|
||||
chat.addEventListener("click", (e) => {
|
||||
const msgDiv = e.target.closest(".msg");
|
||||
if (!msgDiv) return;
|
||||
const id = Number(msgDiv.dataset.id);
|
||||
if (!isFinite(id)) return;
|
||||
const threads = getThreads();
|
||||
const author = threads.get(id)?.username;
|
||||
setReplyTo(id);
|
||||
setFocused(id);
|
||||
input.placeholder = author ? `Replying to @${author}` : "";
|
||||
renderChat(chat, inputContainer);
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Go back to root.
|
||||
rootButton.addEventListener("click", () => {
|
||||
setReplyTo(null);
|
||||
clearFocused();
|
||||
input.placeholder = "";
|
||||
renderChat(chat, inputContainer);
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// Global Escape handler: clear reply context, focus input, and clear focused message.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
setReplyTo(null);
|
||||
clearFocused();
|
||||
input.placeholder = "";
|
||||
renderChat(chat, inputContainer);
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Arrow key navigation for focused messages (also sets reply target).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
const msgs = Array.from(chat.querySelectorAll("div.msg"));
|
||||
if (!msgs.length) return;
|
||||
let idx = msgs.findIndex(
|
||||
(div) => Number(div.dataset.id) === getFocused(),
|
||||
);
|
||||
if (idx === -1) {
|
||||
idx = e.key === "ArrowDown" ? -1 : msgs.length;
|
||||
}
|
||||
idx =
|
||||
e.key === "ArrowDown"
|
||||
? Math.min(msgs.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
const newId = Number(msgs[idx].dataset.id);
|
||||
setFocused(newId);
|
||||
setReplyTo(newId); // Move input box under focused message.
|
||||
const threads = getThreads();
|
||||
const author = threads.get(newId)?.username;
|
||||
input.placeholder = author ? `Replying to @${author}` : "";
|
||||
renderChat(chat, inputContainer);
|
||||
const newDiv = chat.querySelector(`div[data-id="${newId}"]`);
|
||||
if (newDiv) newDiv.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
});
|
||||
194
client/public/data.js
Normal file
194
client/public/data.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// coms/client/public/data.js
|
||||
// Data module: holds the in‐memory threads, notices and reply state
|
||||
|
||||
// Map of all messages and notices by ID
|
||||
// Client’s own session ID
|
||||
let myId = null;
|
||||
const threads = new Map();
|
||||
|
||||
// Map of connected users by unique ID
|
||||
const users = new Map();
|
||||
|
||||
// Ordered list of root‐level IDs (messages and notices)
|
||||
const rootIds = [];
|
||||
|
||||
// Negative counter to generate unique IDs for notices
|
||||
let noticeCounter = -1;
|
||||
|
||||
// ID of the message we’re currently replying to (or null)
|
||||
let replyTo = null;
|
||||
|
||||
// ID of the message currently focused for navigation (or null)
|
||||
let focusedId = null;
|
||||
|
||||
/**
|
||||
* Set the focused message ID (or null to clear).
|
||||
* @param {number|null} id
|
||||
*/
|
||||
function setFocused(id) {
|
||||
focusedId = id;
|
||||
}
|
||||
/**
|
||||
* Get the currently focused message ID.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function getFocused() {
|
||||
return focusedId;
|
||||
}
|
||||
/**
|
||||
* Clear the current focus.
|
||||
*/
|
||||
function clearFocused() {
|
||||
focusedId = null;
|
||||
}
|
||||
|
||||
// -- User management API ---
|
||||
|
||||
/**
|
||||
* Register a user with their unique ID.
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
function addUser(id, name) {
|
||||
users.set(id, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user by their ID.
|
||||
* @param {number} id
|
||||
*/
|
||||
function removeUser(id) {
|
||||
users.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user’s name.
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
function updateUser(id, name) {
|
||||
users.set(id, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of {id, name} objects for all users.
|
||||
* @returns {{id:number, name:string}[]}
|
||||
*/
|
||||
function getUsers() {
|
||||
return Array.from(users.entries()).map(([id, name]) => ({ id, name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get grouped display strings: e.g. ["Anon (3x)", "Alice (1x)"]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getGroupedUsers() {
|
||||
const counts = {};
|
||||
for (const name of users.values()) {
|
||||
counts[name] = (counts[name] || 0) + 1;
|
||||
}
|
||||
return Object.entries(counts).map(([name, count]) => {
|
||||
const nameSpan = `<span class="name">${new Option(name).innerHTML}</span>`;
|
||||
return count > 1 ? `${nameSpan} (${count}x)` : nameSpan;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new chat message to the thread structure.
|
||||
* @param {{id: number, username: string, content: string, ts: number, parent?: number}} msg
|
||||
*/
|
||||
function addMessage({ id, username, content, ts, parent = null }) {
|
||||
threads.set(id, { id, username, content, ts, parent, children: [] });
|
||||
if (parent !== null && threads.has(parent)) {
|
||||
threads.get(parent).children.push(id);
|
||||
} else {
|
||||
rootIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a system notice as a root‐level thread item.
|
||||
* @param {string} content — HTML or plain text for the notice
|
||||
*/
|
||||
function addNotice(content) {
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const id = noticeCounter--;
|
||||
threads.set(id, {
|
||||
id,
|
||||
username: "",
|
||||
content,
|
||||
ts,
|
||||
parent: null,
|
||||
children: [],
|
||||
});
|
||||
rootIds.push(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all data (useful for unit tests or full re‐initialization).
|
||||
*/
|
||||
function clearData() {
|
||||
threads.clear();
|
||||
rootIds.length = 0;
|
||||
noticeCounter = -1;
|
||||
replyTo = null;
|
||||
}
|
||||
|
||||
/** @returns {Map<number,object>} The threads map. */
|
||||
function getThreads() {
|
||||
return threads;
|
||||
}
|
||||
|
||||
/** @returns {number[]} The ordered list of root‐level IDs. */
|
||||
function getRootIds() {
|
||||
return rootIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this client’s own session ID.
|
||||
* @param {number} id
|
||||
*/
|
||||
function setMyId(id) {
|
||||
myId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this client’s own session ID.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function getMyId() {
|
||||
return myId;
|
||||
}
|
||||
|
||||
/** @returns {number|null} The current reply‐to ID. */
|
||||
function getReplyTo() {
|
||||
return replyTo;
|
||||
}
|
||||
|
||||
/** @param {number|null} id — Set the current reply‐to ID. */
|
||||
function setReplyTo(id) {
|
||||
replyTo = id;
|
||||
}
|
||||
|
||||
export {
|
||||
threads,
|
||||
rootIds,
|
||||
users,
|
||||
addMessage,
|
||||
addNotice,
|
||||
addUser,
|
||||
removeUser,
|
||||
updateUser,
|
||||
getUsers,
|
||||
getGroupedUsers,
|
||||
clearData,
|
||||
getThreads,
|
||||
getRootIds,
|
||||
getReplyTo,
|
||||
setReplyTo,
|
||||
getFocused,
|
||||
setFocused,
|
||||
clearFocused,
|
||||
setMyId,
|
||||
getMyId,
|
||||
};
|
||||
63
client/public/index.html
Normal file
63
client/public/index.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat">
|
||||
<div id="inputContainer">
|
||||
<button id="rootButton" class="thread-control">Root</button>
|
||||
<input type="text" id="messageInput" placeholder="" />
|
||||
<button id="sendButton">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bottom_group">
|
||||
<div id="userList">
|
||||
<b>Online:</b>
|
||||
</div>
|
||||
|
||||
<div id="bottom_bar">
|
||||
<div id="name_area">
|
||||
<input
|
||||
type="text"
|
||||
id="nameInput"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
<button id="nameButton">Set Name</button>
|
||||
</div>
|
||||
<div id="bottom_text">
|
||||
<i>COMS</i> by Jacob Signorovitch.
|
||||
<a href="#" id="help">Help</a> |
|
||||
<a href="//signorovitch.org">Code</a> |
|
||||
<a href="//signorovitch.org">API</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
<dialog id="help_dialog">
|
||||
Click on messages to reply to them.<br />
|
||||
<kbd>Enter</kbd> to send the current message.<br />
|
||||
<kbd>Esc</kbd> to cancel a reply.<br />
|
||||
<kbd>↑</kbd> and <kbd>↓</kbd> to navigate the message tree by
|
||||
keyboard.
|
||||
<a id="help_dismiss" href="#">Dismiss.</a>
|
||||
</dialog>
|
||||
<script>
|
||||
const help = document.getElementById("help");
|
||||
const help_dialog = document.getElementById("help_dialog");
|
||||
const help_dismiss = document.getElementById("help_dismiss");
|
||||
help.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
help_dialog.showModal();
|
||||
});
|
||||
help_dismiss.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
help_dialog.close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
104
client/public/render.js
Normal file
104
client/public/render.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Render module: handles chat UI rendering of messages and notices
|
||||
import { getThreads, getRootIds, getReplyTo, getFocused } from "./data.js";
|
||||
|
||||
/**
|
||||
* Render the chat history and input container into the provided chat element.
|
||||
* @param {HTMLElement} chatEl - The container element for chat entries.
|
||||
* @param {HTMLElement} inputContainer - The message input area to append at bottom.
|
||||
*/
|
||||
export function renderChat(chatEl, inputContainer) {
|
||||
// Clear existing content.
|
||||
chatEl.innerHTML = "";
|
||||
|
||||
const threads = getThreads();
|
||||
const rootIds = getRootIds();
|
||||
const replyTo = getReplyTo();
|
||||
let replyDepth = 0;
|
||||
if (replyTo !== null) {
|
||||
let cur = threads.get(replyTo);
|
||||
while (cur && cur.parent != null) {
|
||||
replyDepth++;
|
||||
cur = threads.get(cur.parent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render a thread node and its children.
|
||||
* @param {number} id - The message or notice ID.
|
||||
* @param {number} depth - Nesting level (for indentation).
|
||||
*/
|
||||
function renderNode(id, depth = 0) {
|
||||
const msg = threads.get(id);
|
||||
if (!msg) return;
|
||||
|
||||
// Create wrapper div.
|
||||
const div = document.createElement("div");
|
||||
div.dataset.id = id;
|
||||
div.style.marginLeft = `${depth}em`;
|
||||
|
||||
// Format timestamp.
|
||||
const date = new Date(msg.ts * 1000);
|
||||
let h = date.getHours();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
let m = date.getMinutes();
|
||||
if (m < 10) m = "0" + m;
|
||||
const fullTimestamp =
|
||||
`${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()} ` +
|
||||
`${h}:${m} ${ampm}`;
|
||||
const shortTimestamp = `${h}:${m} ${ampm}`;
|
||||
const tsSpan = `<span class="ts" title="${fullTimestamp}">${shortTimestamp}</span>`;
|
||||
|
||||
// Distinguish notice vs normal message.
|
||||
if (msg.username) {
|
||||
div.classList.add("msg");
|
||||
if (getFocused() === id) div.classList.add("focused");
|
||||
div.innerHTML = `${tsSpan} <span class="name">${new Option(msg.username).innerHTML}:</span> <span class="msg_content">${new Option(msg.content).innerHTML}</span>`;
|
||||
} else {
|
||||
div.classList.add("notice");
|
||||
div.innerHTML = `${tsSpan} ${msg.content}`;
|
||||
}
|
||||
|
||||
// Append to chat and render children.
|
||||
chatEl.appendChild(div);
|
||||
msg.children.forEach((childId) => renderNode(childId, depth + 1));
|
||||
}
|
||||
|
||||
// Render all root-level nodes in order.
|
||||
rootIds.forEach((id) => renderNode(id));
|
||||
|
||||
// Append input container at appropriate thread level.
|
||||
{
|
||||
const indent = replyTo !== null ? replyDepth + 1 : 0;
|
||||
inputContainer.style.paddingLeft = `${indent}em`;
|
||||
|
||||
// Place input under the last descendant if replying, else at bottom.
|
||||
let refDiv = null;
|
||||
if (replyTo !== null) {
|
||||
function lastDesc(id) {
|
||||
const node = threads.get(id);
|
||||
return !node || node.children.length === 0
|
||||
? id
|
||||
: lastDesc(node.children[node.children.length - 1]);
|
||||
}
|
||||
const lastId = lastDesc(replyTo);
|
||||
refDiv = chatEl.querySelector(`div[data-id="${lastId}"]`);
|
||||
}
|
||||
if (refDiv) {
|
||||
refDiv.insertAdjacentElement("afterend", inputContainer);
|
||||
} else {
|
||||
chatEl.appendChild(inputContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to bottom.
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
const input = inputContainer.querySelector("input");
|
||||
if (input) {
|
||||
input.focus();
|
||||
// Ensure inputContainer margin resets if at root.
|
||||
if (replyTo === null) {
|
||||
inputContainer.style.marginLeft = "0";
|
||||
}
|
||||
}
|
||||
}
|
||||
165
client/public/style.css
Normal file
165
client/public/style.css
Normal file
@@ -0,0 +1,165 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#chat {
|
||||
overflow-y: scroll;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: gainsboro;
|
||||
border: none;
|
||||
height: 100%;
|
||||
font-size: inherit;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
background-color: darkgray;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
#userList {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
button,
|
||||
kbd {
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
border-radius: 0;
|
||||
background-color: gainsboro;
|
||||
color: black;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: darkgray;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-bottom: 2px;
|
||||
display: inline-flexbox;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.msg_content {
|
||||
word-wrap: break-word;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background-color: wheat;
|
||||
width: fit-content;
|
||||
font-style: italic;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.ts {
|
||||
color: gray;
|
||||
font-size: 0.6em;
|
||||
margin-right: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
font-style: normal;
|
||||
align-self: self-end;
|
||||
}
|
||||
|
||||
#inputContainer {
|
||||
box-sizing: border-box;
|
||||
padding-left: 0;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
#bottom_group {
|
||||
position: sticky;
|
||||
bottom: 0px;
|
||||
background-color: white;
|
||||
padding-bottom: 10px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
#bottom_bar {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#name_area {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#nameInput {
|
||||
flex-grow: 0;
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
#bottom_text {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
background-color: gainsboro;
|
||||
height: 100%;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.err {
|
||||
font-weight: bold;
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
background-color: white;
|
||||
padding: 5em;
|
||||
}
|
||||
|
||||
::backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.msg.focused {
|
||||
background-color: lightblue;
|
||||
}
|
||||
129
client/public/wsModule.js
Normal file
129
client/public/wsModule.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* coms/client/public/wsModule.js
|
||||
* WebSocket module: handles connection, dispatching incoming packets to
|
||||
* data and render modules, and provides a sendMessage API.
|
||||
*/
|
||||
|
||||
import {
|
||||
addMessage,
|
||||
addNotice,
|
||||
getReplyTo,
|
||||
addUser,
|
||||
updateUser,
|
||||
getGroupedUsers,
|
||||
setMyId,
|
||||
users,
|
||||
setFocused,
|
||||
setReplyTo,
|
||||
} from "./data.js";
|
||||
import { renderChat } from "./render.js";
|
||||
|
||||
/**
|
||||
* Initialize WebSocket connection and wire up event handlers.
|
||||
*
|
||||
* @param {string} username - The user's chosen name.
|
||||
* @param {HTMLElement} chatEl - The container element for chat entries.
|
||||
* @param {HTMLElement} inputContainer - The message input area element.
|
||||
* @param {HTMLElement} usersListEl - The element displaying the online users.
|
||||
* @returns {{ socket: WebSocket, sendMessage: (content: string) => void }}
|
||||
*/
|
||||
export function initWebSocket(username, chatEl, inputContainer, usersListEl) {
|
||||
const socket = new WebSocket("ws://localhost:8080", "coms");
|
||||
|
||||
socket.onopen = () => {
|
||||
// Announce join.
|
||||
socket.send(JSON.stringify({ type: "join", data: { username } }));
|
||||
//addNotice(`Connected as <span class="name">${username}</span>`);
|
||||
renderChat(chatEl, inputContainer);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const packet = JSON.parse(event.data);
|
||||
switch (packet.type) {
|
||||
case "welcome": {
|
||||
// Initial user list.
|
||||
const myId = packet.data.you;
|
||||
setMyId(myId);
|
||||
const users = packet.data.users || [];
|
||||
users.forEach(({ id, username }) => addUser(id, username));
|
||||
const groups = getGroupedUsers();
|
||||
usersListEl.innerHTML = "Online: " + groups.join(", ");
|
||||
//addNotice(`<i>Users online: ${groups.join(", ")}</i>`);
|
||||
break;
|
||||
}
|
||||
case "join-event": {
|
||||
// A new user joined.
|
||||
const { id, username } = packet.data;
|
||||
addUser(id, username);
|
||||
addNotice(
|
||||
`<span class="name">${new Option(username).innerHTML}</span> joined.`,
|
||||
);
|
||||
const groups = getGroupedUsers();
|
||||
usersListEl.innerHTML = "Online: " + groups.join(", ");
|
||||
break;
|
||||
}
|
||||
case "name-event": {
|
||||
// A user changed name.
|
||||
const { id, new: newName, old: oldName } = packet.data;
|
||||
updateUser(id, newName);
|
||||
addNotice(
|
||||
`<span class="name">${new Option(oldName).innerHTML}</span> changed name to <span class="name">${new Option(newName).innerHTML}</span>`,
|
||||
);
|
||||
const groups = getGroupedUsers();
|
||||
usersListEl.innerHTML = "Online: " + groups.join(", ");
|
||||
break;
|
||||
}
|
||||
case "msg-event": {
|
||||
// A chat message arrived.
|
||||
const { id, username: u, content, ts, parent } = packet.data;
|
||||
addMessage({ id, username: u, content, ts, parent });
|
||||
break;
|
||||
}
|
||||
case "msg-ack": {
|
||||
const { id } = packet.data;
|
||||
if (getReplyTo() === null) {
|
||||
setFocused(id);
|
||||
setReplyTo(id);
|
||||
}
|
||||
renderChat(chatEl, inputContainer);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("Unknown packet type:", packet.type);
|
||||
}
|
||||
|
||||
renderChat(chatEl, inputContainer);
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error("WebSocket error:", err);
|
||||
addNotice(
|
||||
'<span class="err">WebSocket error. See console for details.</span>',
|
||||
);
|
||||
renderChat(chatEl, inputContainer);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
addNotice("Disconnected.");
|
||||
renderChat(chatEl, inputContainer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a chat message via WebSocket.
|
||||
* @param {string} content - The message text to send.
|
||||
*/
|
||||
function sendMessage(content) {
|
||||
const parent = getReplyTo();
|
||||
socket.send(JSON.stringify({ type: "msg", data: { content, parent } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a name change packet via WebSocket.
|
||||
* @param {string} username - The new username to set.
|
||||
*/
|
||||
function sendName(username) {
|
||||
socket.send(JSON.stringify({ type: "name", data: { username } }));
|
||||
}
|
||||
|
||||
return { socket, sendMessage, sendName };
|
||||
}
|
||||
21
client/server.js
Normal file
21
client/server.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const livereload = require("livereload");
|
||||
const connectLivereload = require("connect-livereload");
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
const liveReloadServer = livereload.createServer();
|
||||
liveReloadServer.watch(path.join(__dirname, "public"));
|
||||
app.use(connectLivereload());
|
||||
|
||||
app.use(express.static("public"));
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`UI Server running at http://localhost:${PORT}`);
|
||||
console.log(`Make sure your C server is running on port 8080!`);
|
||||
});
|
||||
Reference in New Issue
Block a user