Added code.
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# C build artifacts.
|
||||||
|
server/build/
|
||||||
|
server/*.o
|
||||||
|
server/*.out
|
||||||
|
|
||||||
|
# Node dependencies.
|
||||||
|
client/node_modules/
|
||||||
|
|
||||||
|
# Logs.
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment files.
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# COMS
|
||||||
|
|
||||||
|
Commonwealth Oriented Message System.
|
||||||
|
|
||||||
|
IRC-like instant messaging system with infinite threading support. Server is
|
||||||
|
written in C, and the client server in nodejs.
|
||||||
|
|
||||||
|
Depends on [libwebsockets](https://libwebsockets.org/).
|
||||||
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!`);
|
||||||
|
});
|
||||||
14
server/.clang-format
Normal file
14
server/.clang-format
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
AlignConsecutiveShortCaseStatements:
|
||||||
|
Enabled: true
|
||||||
|
AcrossEmptyLines: true
|
||||||
|
AcrossComments: true
|
||||||
|
IndentCaseLabels: true
|
||||||
|
AllowShortBlocksOnASingleLine: Always
|
||||||
|
AllowShortCaseLabelsOnASingleLine: true
|
||||||
|
AllowShortEnumsOnASingleLine: true
|
||||||
|
AllowShortIfStatementsOnASingleLine: AllIfsAndElse
|
||||||
|
AllowShortLoopsOnASingleLine: true
|
||||||
|
IndentWidth: 4
|
||||||
|
PointerAlignment: Left
|
||||||
|
AlignAfterOpenBracket: BlockIndent
|
||||||
2
server/.clangd
Normal file
2
server/.clangd
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
CompileFlags:
|
||||||
|
Add: [-xc]
|
||||||
33
server/Makefile
Normal file
33
server/Makefile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
include config.mk
|
||||||
|
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
|
release: clean
|
||||||
|
release: CFLAGS += -O2
|
||||||
|
release: $(TARGET)
|
||||||
|
|
||||||
|
# Run the target.
|
||||||
|
run: $(TARGET)
|
||||||
|
./$(TARGET)
|
||||||
|
|
||||||
|
# Compile project source objects.
|
||||||
|
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c $(INC_DIR)/%.h
|
||||||
|
@ mkdir -p $(OBJ_DIR)
|
||||||
|
@ $(PRINT) "$(WHITE_BOLD)Compiling source object $(WHITE)$@$(WHITE_BOLD)... $(RESETCOLOR)"
|
||||||
|
$(CC) $(CFLAGS) -c $< -o $@
|
||||||
|
|
||||||
|
# Link to final binary.
|
||||||
|
$(TARGET): $(OBJ_FILES)
|
||||||
|
@ $(PRINT) "$(WHITE_BOLD)Linking $(WHITE)$@$(WHITE_BOLD)...$(RESETCOLOR)"
|
||||||
|
$(LINK) -o $(TARGET) $(OBJ_FILES) $(LDFLAGS)
|
||||||
|
|
||||||
|
# Clean out objects, binaries, and built artifacts.
|
||||||
|
clean:
|
||||||
|
@ $(PRINT) "$(WHITE_BOLD)Cleaning up...$(RESETCOLOR)"
|
||||||
|
rm -rf $(OBJ_DIR)/*.o $(TARGET)
|
||||||
|
|
||||||
|
# Get LOC.
|
||||||
|
lines:
|
||||||
|
@ wc -l $(SRC_FILES) $(INC_FILES)
|
||||||
|
|
||||||
|
.PHONY: all clean test nocolor release run lines
|
||||||
24
server/config.mk
Normal file
24
server/config.mk
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
NAME = coms
|
||||||
|
|
||||||
|
TARGET = $(NAME).out
|
||||||
|
|
||||||
|
SRC_DIR = src
|
||||||
|
INC_DIR = $(SRC_DIR)/include
|
||||||
|
BUILD_DIR = build
|
||||||
|
OBJ_DIR = $(BUILD_DIR)/obj
|
||||||
|
|
||||||
|
CC = clang -std=c23
|
||||||
|
LINK = clang
|
||||||
|
CFLAGS = -Wall -DDBG -ggdb -fsanitize=leak -I$(INC_DIR) -I$(SRC_DIR)
|
||||||
|
LDFLAGS = -lwebsockets
|
||||||
|
PRINT = echo -e
|
||||||
|
|
||||||
|
SRC_FILES = $(wildcard $(SRC_DIR)/*.c)
|
||||||
|
INC_FILES = $(wildcard $(INC_DIR)/*.h)
|
||||||
|
OBJ_FILES = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRC_FILES))
|
||||||
|
OBJ_FILES_NOMAIN = $(filter-out $(OBJ_DIR)/main.o, $(OBJ_FILES)) # Object files without main.c.
|
||||||
|
|
||||||
|
RESETCOLOR = \033[0m
|
||||||
|
WHITE = $(RESETCOLOR)\033[0m
|
||||||
|
WHITE_BOLD = $(RESETCOLOR)\033[0;1m
|
||||||
|
RED_BOLD = $(RESETCOLOR)\033[31;1m
|
||||||
331
server/src/chat.c
Normal file
331
server/src/chat.c
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// TODO: Make types for allt he proto events. This is quite messy without that.
|
||||||
|
|
||||||
|
#include "include/chat.h"
|
||||||
|
#include "include/session.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <libwebsockets.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <yyjson.h>
|
||||||
|
|
||||||
|
static size_t next_msg_id = 1;
|
||||||
|
|
||||||
|
#define CHAT_BUF_SIZE SESSION_CHAT_BUF_SIZE
|
||||||
|
|
||||||
|
/*
|
||||||
|
* cb_chat - libwebsockets protocol callback.
|
||||||
|
|
||||||
|
* Handles connection lifecycle, incoming messages, and writable events.
|
||||||
|
*/
|
||||||
|
int cb_chat(
|
||||||
|
struct lws* wsi, enum lws_callback_reasons reason, void* user, void* in,
|
||||||
|
size_t len
|
||||||
|
) {
|
||||||
|
Session** ps_p = (Session**)user;
|
||||||
|
Session* sess = ps_p ? *ps_p : NULL;
|
||||||
|
Session* head = session_get_head();
|
||||||
|
|
||||||
|
switch (reason) {
|
||||||
|
|
||||||
|
case LWS_CALLBACK_ESTABLISHED:
|
||||||
|
// New connection, create session.
|
||||||
|
if (ps_p) { *ps_p = session_create(wsi); }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LWS_CALLBACK_RECEIVE: {
|
||||||
|
// Parse inc JSON packet.
|
||||||
|
if (len > CHAT_BUF_SIZE) {
|
||||||
|
lwsl_warn(
|
||||||
|
"Received JSON payload exceeds limit: %zu bytes\n", len
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yyjson_doc* doc = yyjson_read((const char*)in, len, 0);
|
||||||
|
if (!doc) break;
|
||||||
|
const yyjson_val* root = yyjson_doc_get_root(doc);
|
||||||
|
const yyjson_val* tval = yyjson_obj_get((yyjson_val*)root, "type");
|
||||||
|
const char* type = (tval && yyjson_is_str((yyjson_val*)tval))
|
||||||
|
? yyjson_get_str((yyjson_val*)tval)
|
||||||
|
: NULL;
|
||||||
|
|
||||||
|
// JOIN.
|
||||||
|
if (type && strcmp(type, "join") == 0 && sess) {
|
||||||
|
const yyjson_val* data =
|
||||||
|
yyjson_obj_get((yyjson_val*)root, "data");
|
||||||
|
const yyjson_val* uval =
|
||||||
|
data ? yyjson_obj_get((yyjson_val*)data, "username") : NULL;
|
||||||
|
// Enforce max username length.
|
||||||
|
const char* raw_name =
|
||||||
|
(uval && yyjson_is_str((yyjson_val*)uval))
|
||||||
|
? yyjson_get_str((yyjson_val*)uval)
|
||||||
|
: "Anonymous";
|
||||||
|
if (raw_name[0] == '\0') { raw_name = "Anonymous"; }
|
||||||
|
char name_buf[SESSION_USERNAME_MAX_LEN];
|
||||||
|
size_t _i;
|
||||||
|
for (_i = 0; raw_name[_i] && _i < SESSION_USERNAME_MAX_LEN - 1;
|
||||||
|
++_i) {
|
||||||
|
unsigned char c = (unsigned char)raw_name[_i];
|
||||||
|
name_buf[_i] = isprint(c) ? c : '?';
|
||||||
|
}
|
||||||
|
name_buf[_i] = '\0';
|
||||||
|
session_set_username(sess, name_buf);
|
||||||
|
|
||||||
|
// #1: Welcome our new client.
|
||||||
|
{
|
||||||
|
yyjson_mut_doc* wdoc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val* wroot = yyjson_mut_obj(wdoc);
|
||||||
|
yyjson_mut_doc_set_root(wdoc, wroot);
|
||||||
|
yyjson_mut_obj_add_str(wdoc, wroot, "type", "welcome");
|
||||||
|
|
||||||
|
yyjson_mut_val* wdata = yyjson_mut_obj(wdoc);
|
||||||
|
yyjson_mut_obj_add_val(wdoc, wroot, "data", wdata);
|
||||||
|
yyjson_mut_obj_add_uint(
|
||||||
|
wdoc, wdata, "you", session_get_id(sess)
|
||||||
|
);
|
||||||
|
|
||||||
|
yyjson_mut_val* wusers = yyjson_mut_arr(wdoc);
|
||||||
|
yyjson_mut_obj_add_val(wdoc, wdata, "users", wusers);
|
||||||
|
|
||||||
|
for (Session* s = head; s; s = s->next) {
|
||||||
|
if (session_has_username(s)) {
|
||||||
|
yyjson_mut_val* uobj = yyjson_mut_obj(wdoc);
|
||||||
|
yyjson_mut_arr_add_val(wusers, uobj);
|
||||||
|
yyjson_mut_obj_add_uint(
|
||||||
|
wdoc, uobj, "id", session_get_id(s)
|
||||||
|
);
|
||||||
|
yyjson_mut_obj_add_str(
|
||||||
|
wdoc, uobj, "username", session_get_username(s)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t out_len;
|
||||||
|
char* out = yyjson_mut_write(wdoc, 0, &out_len);
|
||||||
|
size_t copy_len = out_len < SESSION_CHAT_BUF_SIZE
|
||||||
|
? out_len
|
||||||
|
: SESSION_CHAT_BUF_SIZE;
|
||||||
|
sess->buf_len = copy_len;
|
||||||
|
memcpy(&sess->buf[LWS_PRE], out, copy_len);
|
||||||
|
lws_write(
|
||||||
|
sess->wsi, &sess->buf[LWS_PRE], sess->buf_len,
|
||||||
|
LWS_WRITE_TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
free(out);
|
||||||
|
yyjson_mut_doc_free(wdoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #2: Introduce our new client to everybody else.
|
||||||
|
{
|
||||||
|
yyjson_mut_doc* jdoc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val* jroot = yyjson_mut_obj(jdoc);
|
||||||
|
yyjson_mut_doc_set_root(jdoc, jroot);
|
||||||
|
yyjson_mut_obj_add_str(jdoc, jroot, "type", "join-event");
|
||||||
|
|
||||||
|
yyjson_mut_val* jdata = yyjson_mut_obj(jdoc);
|
||||||
|
yyjson_mut_obj_add_val(jdoc, jroot, "data", jdata);
|
||||||
|
yyjson_mut_obj_add_uint(
|
||||||
|
jdoc, jdata, "id", session_get_id(sess)
|
||||||
|
);
|
||||||
|
yyjson_mut_obj_add_str(
|
||||||
|
jdoc, jdata, "username", session_get_username(sess)
|
||||||
|
);
|
||||||
|
|
||||||
|
size_t out_len;
|
||||||
|
char* out = yyjson_mut_write(jdoc, 0, &out_len);
|
||||||
|
size_t copy_len = out_len < SESSION_CHAT_BUF_SIZE
|
||||||
|
? out_len
|
||||||
|
: SESSION_CHAT_BUF_SIZE;
|
||||||
|
for (Session* s = head; s; s = s->next) {
|
||||||
|
s->buf_len = copy_len;
|
||||||
|
memcpy(&s->buf[LWS_PRE], out, copy_len);
|
||||||
|
lws_callback_on_writable(s->wsi);
|
||||||
|
}
|
||||||
|
free(out);
|
||||||
|
yyjson_mut_doc_free(jdoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// NAME.
|
||||||
|
else if (type && strcmp(type, "name") == 0 && sess &&
|
||||||
|
session_has_username(sess)) {
|
||||||
|
const yyjson_val* dataVal =
|
||||||
|
root ? yyjson_obj_get((yyjson_val*)root, "data") : NULL;
|
||||||
|
const yyjson_val* nval =
|
||||||
|
dataVal ? yyjson_obj_get((yyjson_val*)dataVal, "username")
|
||||||
|
: NULL;
|
||||||
|
// Enforce max name length.
|
||||||
|
const char* raw_newname =
|
||||||
|
(nval && yyjson_is_str((yyjson_val*)nval))
|
||||||
|
? yyjson_get_str((yyjson_val*)nval)
|
||||||
|
: NULL;
|
||||||
|
char newname_buf[SESSION_USERNAME_MAX_LEN];
|
||||||
|
if (raw_newname && raw_newname[0] != '\0') {
|
||||||
|
size_t _j;
|
||||||
|
for (_j = 0;
|
||||||
|
raw_newname[_j] && _j < SESSION_USERNAME_MAX_LEN - 1;
|
||||||
|
++_j) {
|
||||||
|
unsigned char c = (unsigned char)raw_newname[_j];
|
||||||
|
newname_buf[_j] = isprint(c) ? c : '?';
|
||||||
|
}
|
||||||
|
newname_buf[_j] = '\0';
|
||||||
|
} else {
|
||||||
|
// Disallow empty names.
|
||||||
|
strcpy(newname_buf, session_get_username(sess));
|
||||||
|
}
|
||||||
|
const char* newname = newname_buf;
|
||||||
|
// Buffer old name before updating.
|
||||||
|
char oldname_buf[SESSION_USERNAME_MAX_LEN];
|
||||||
|
const char* current = session_get_username(sess);
|
||||||
|
if (current) {
|
||||||
|
strncpy(oldname_buf, current, SESSION_USERNAME_MAX_LEN - 1);
|
||||||
|
oldname_buf[SESSION_USERNAME_MAX_LEN - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
oldname_buf[0] = '\0';
|
||||||
|
}
|
||||||
|
// Now update to new name.
|
||||||
|
session_set_username(sess, newname);
|
||||||
|
|
||||||
|
// Broadcast name-event to other clients.
|
||||||
|
{
|
||||||
|
yyjson_mut_doc* ndoc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val* nroot = yyjson_mut_obj(ndoc);
|
||||||
|
yyjson_mut_doc_set_root(ndoc, nroot);
|
||||||
|
yyjson_mut_obj_add_str(ndoc, nroot, "type", "name-event");
|
||||||
|
yyjson_mut_val* ndata = yyjson_mut_obj(ndoc);
|
||||||
|
yyjson_mut_obj_add_val(ndoc, nroot, "data", ndata);
|
||||||
|
yyjson_mut_obj_add_uint(
|
||||||
|
ndoc, ndata, "id", session_get_id(sess)
|
||||||
|
);
|
||||||
|
yyjson_mut_obj_add_str(ndoc, ndata, "old", oldname_buf);
|
||||||
|
yyjson_mut_obj_add_str(ndoc, ndata, "new", newname);
|
||||||
|
|
||||||
|
size_t out_len;
|
||||||
|
char* out = yyjson_mut_write(ndoc, 0, &out_len);
|
||||||
|
size_t copy_len = out_len < SESSION_CHAT_BUF_SIZE
|
||||||
|
? out_len
|
||||||
|
: SESSION_CHAT_BUF_SIZE;
|
||||||
|
for (Session* s = head; s; s = s->next) {
|
||||||
|
s->buf_len = copy_len;
|
||||||
|
memcpy(&s->buf[LWS_PRE], out, copy_len);
|
||||||
|
lws_callback_on_writable(s->wsi);
|
||||||
|
}
|
||||||
|
free(out);
|
||||||
|
yyjson_mut_doc_free(ndoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MSG.
|
||||||
|
else if (type && strcmp(type, "msg") == 0 && sess &&
|
||||||
|
session_has_username(sess)) {
|
||||||
|
const yyjson_val* data =
|
||||||
|
yyjson_obj_get((yyjson_val*)root, "data");
|
||||||
|
const yyjson_val* cval =
|
||||||
|
data ? yyjson_obj_get((yyjson_val*)data, "content") : NULL;
|
||||||
|
// Enforce maximum message content length
|
||||||
|
const char* raw_msg = (cval && yyjson_is_str((yyjson_val*)cval))
|
||||||
|
? yyjson_get_str((yyjson_val*)cval)
|
||||||
|
: "";
|
||||||
|
char msg_buf[CHAT_BUF_SIZE];
|
||||||
|
strncpy(msg_buf, raw_msg, CHAT_BUF_SIZE - 1);
|
||||||
|
msg_buf[CHAT_BUF_SIZE - 1] = '\0';
|
||||||
|
// Sanitize message to printable characters.
|
||||||
|
for (size_t _k = 0; msg_buf[_k]; ++_k) {
|
||||||
|
unsigned char c = (unsigned char)msg_buf[_k];
|
||||||
|
if (!isprint(c)) { msg_buf[_k] = '?'; }
|
||||||
|
}
|
||||||
|
const char* msg = msg_buf;
|
||||||
|
|
||||||
|
// Build msg-event JSON with ID, parent and timestamp.
|
||||||
|
// Get parent ID if present.
|
||||||
|
const yyjson_val* dataVal =
|
||||||
|
yyjson_obj_get((yyjson_val*)root, "data");
|
||||||
|
const yyjson_val* pval =
|
||||||
|
dataVal ? yyjson_obj_get((yyjson_val*)dataVal, "parent")
|
||||||
|
: NULL;
|
||||||
|
uint64_t parent_id = (pval && yyjson_is_uint((yyjson_val*)pval))
|
||||||
|
? yyjson_get_uint((yyjson_val*)pval)
|
||||||
|
: 0;
|
||||||
|
time_t now = time(NULL);
|
||||||
|
size_t msg_id = next_msg_id++;
|
||||||
|
// Send ack with the new message ID.
|
||||||
|
{
|
||||||
|
yyjson_mut_doc* ackdoc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val* ackroot = yyjson_mut_obj(ackdoc);
|
||||||
|
yyjson_mut_doc_set_root(ackdoc, ackroot);
|
||||||
|
yyjson_mut_obj_add_str(ackdoc, ackroot, "type", "msg-ack");
|
||||||
|
yyjson_mut_val* ackdat = yyjson_mut_obj(ackdoc);
|
||||||
|
yyjson_mut_obj_add_val(ackdoc, ackroot, "data", ackdat);
|
||||||
|
yyjson_mut_obj_add_uint(ackdoc, ackdat, "id", msg_id);
|
||||||
|
size_t ack_len;
|
||||||
|
char* ack_out = yyjson_mut_write(ackdoc, 0, &ack_len);
|
||||||
|
size_t copy_ack = ack_len < SESSION_CHAT_BUF_SIZE
|
||||||
|
? ack_len
|
||||||
|
: SESSION_CHAT_BUF_SIZE;
|
||||||
|
sess->buf_len = copy_ack;
|
||||||
|
memcpy(&sess->buf[LWS_PRE], ack_out, copy_ack);
|
||||||
|
lws_write(
|
||||||
|
sess->wsi, &sess->buf[LWS_PRE], sess->buf_len,
|
||||||
|
LWS_WRITE_TEXT
|
||||||
|
);
|
||||||
|
free(ack_out);
|
||||||
|
yyjson_mut_doc_free(ackdoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
yyjson_mut_doc* mdoc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val* mroot = yyjson_mut_obj(mdoc);
|
||||||
|
yyjson_mut_doc_set_root(mdoc, mroot);
|
||||||
|
yyjson_mut_obj_add_str(mdoc, mroot, "type", "msg-event");
|
||||||
|
|
||||||
|
yyjson_mut_val* mdat = yyjson_mut_obj(mdoc);
|
||||||
|
yyjson_mut_obj_add_val(mdoc, mroot, "data", mdat);
|
||||||
|
yyjson_mut_obj_add_uint(mdoc, mdat, "id", msg_id);
|
||||||
|
if (parent_id)
|
||||||
|
yyjson_mut_obj_add_uint(mdoc, mdat, "parent", parent_id);
|
||||||
|
else yyjson_mut_obj_add_null(mdoc, mdat, "parent");
|
||||||
|
yyjson_mut_obj_add_uint(mdoc, mdat, "ts", (uint64_t)now);
|
||||||
|
yyjson_mut_obj_add_str(
|
||||||
|
mdoc, mdat, "username", session_get_username(sess)
|
||||||
|
);
|
||||||
|
yyjson_mut_obj_add_str(mdoc, mdat, "content", msg);
|
||||||
|
|
||||||
|
size_t out_len;
|
||||||
|
char* out = yyjson_mut_write(mdoc, 0, &out_len);
|
||||||
|
size_t copy_len = out_len < SESSION_CHAT_BUF_SIZE
|
||||||
|
? out_len
|
||||||
|
: SESSION_CHAT_BUF_SIZE;
|
||||||
|
for (Session* s = head; s; s = s->next) {
|
||||||
|
s->buf_len = copy_len;
|
||||||
|
memcpy(&s->buf[LWS_PRE], out, copy_len);
|
||||||
|
lws_callback_on_writable(s->wsi);
|
||||||
|
}
|
||||||
|
free(out);
|
||||||
|
yyjson_mut_doc_free(mdoc);
|
||||||
|
// Writable events already scheduled for each session above.
|
||||||
|
}
|
||||||
|
|
||||||
|
yyjson_doc_free(doc);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LWS_CALLBACK_SERVER_WRITEABLE:
|
||||||
|
if (sess && sess->buf_len > 0) {
|
||||||
|
lws_write(
|
||||||
|
sess->wsi, &sess->buf[LWS_PRE], sess->buf_len,
|
||||||
|
LWS_WRITE_TEXT
|
||||||
|
);
|
||||||
|
sess->buf_len = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LWS_CALLBACK_CLOSED:
|
||||||
|
// Goodbye.
|
||||||
|
// TODO: Add leave event to proto.
|
||||||
|
if (sess) { session_destroy(sess); }
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
25
server/src/include/chat.h
Normal file
25
server/src/include/chat.h
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#ifndef CHAT_H
|
||||||
|
#define CHAT_H
|
||||||
|
|
||||||
|
#include <libwebsockets.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cb_chat - libwebsockets protocol callback for COMS chat.
|
||||||
|
*
|
||||||
|
* This function is registered in the protocols array passed to the
|
||||||
|
* lws_context and handles connection, receive, writable, and close events.
|
||||||
|
*
|
||||||
|
* @wsi: The WebSocket instance pointer
|
||||||
|
* @reason: One of the LWS_CALLBACK_* reasons
|
||||||
|
* @user: Pointer to a session_t* for this connection
|
||||||
|
* @in: Pointer to incoming data (for receive events)
|
||||||
|
* @len: Length of the incoming data buffer
|
||||||
|
*
|
||||||
|
* Return: 0 on success, non-zero on error.
|
||||||
|
*/
|
||||||
|
int cb_chat(
|
||||||
|
struct lws* wsi, enum lws_callback_reasons reason, void* user, void* in,
|
||||||
|
size_t len
|
||||||
|
);
|
||||||
|
|
||||||
|
#endif // CHAT_H
|
||||||
4
server/src/include/main.h
Normal file
4
server/src/include/main.h
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#ifndef MAIN_H
|
||||||
|
#define MAIN_H
|
||||||
|
|
||||||
|
#endif // MAIN_H
|
||||||
96
server/src/include/session.h
Normal file
96
server/src/include/session.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#ifndef SESSION_H
|
||||||
|
#define SESSION_H
|
||||||
|
|
||||||
|
#include <libwebsockets.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// Includes terminating null.
|
||||||
|
#define SESSION_USERNAME_MAX_LEN 32
|
||||||
|
#define SESSION_CHAT_BUF_SIZE 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_t
|
||||||
|
* Represents a single WebSocket client session in the chat server.
|
||||||
|
*/
|
||||||
|
typedef struct SESSION {
|
||||||
|
struct lws* wsi; // Libwebsockets connection handle.
|
||||||
|
uint64_t id; // Unique session ID.
|
||||||
|
struct SESSION* next; // Next session in the internal list.
|
||||||
|
char name[SESSION_USERNAME_MAX_LEN]; // Stored username.
|
||||||
|
bool named; // True once username is set.
|
||||||
|
unsigned char buf[LWS_PRE + SESSION_CHAT_BUF_SIZE]; // Outgoing buffer per session
|
||||||
|
size_t buf_len; // Length of data in buf
|
||||||
|
} Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_create
|
||||||
|
* Allocate and initialize a new session, adding it to the internal list.
|
||||||
|
*
|
||||||
|
* @param wsi The libwebsockets connection handle.
|
||||||
|
* @return Pointer to the new session, or NULL on failure.
|
||||||
|
*/
|
||||||
|
Session* session_create(struct lws* wsi);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_destroy
|
||||||
|
* Remove the given session from the internal list and free its memory.
|
||||||
|
*
|
||||||
|
* @param sess The session to destroy.
|
||||||
|
*/
|
||||||
|
void session_destroy(Session* sess);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_get_head
|
||||||
|
* Retrieve the head of the internal session linked list.
|
||||||
|
*
|
||||||
|
* @return The first session in the list, or NULL if none.
|
||||||
|
*/
|
||||||
|
Session* session_get_head(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_broadcast
|
||||||
|
* Iterate every session in the internal list and invoke the callback.
|
||||||
|
*
|
||||||
|
* @param cb Function to call for each session.
|
||||||
|
* @param user Arbitrary user data passed through to each callback.
|
||||||
|
*/
|
||||||
|
void session_broadcast(void (*cb)(Session* sess, void* user), void* user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_set_username
|
||||||
|
* Store a username in the given session.
|
||||||
|
*
|
||||||
|
* @param sess The session to update.
|
||||||
|
* @param username Null-terminated string to copy into the session.
|
||||||
|
*/
|
||||||
|
void session_set_username(Session* sess, const char* username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_get_username
|
||||||
|
* Fetch the username stored in the session.
|
||||||
|
*
|
||||||
|
* @param sess The session to query.
|
||||||
|
* @return Pointer to the stored username (readonly).
|
||||||
|
*/
|
||||||
|
const char* session_get_username(const Session* sess);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_has_username
|
||||||
|
* Check whether a session has already set a username.
|
||||||
|
*
|
||||||
|
* @param sess The session to query.
|
||||||
|
* @return True if a username is set, false otherwise.
|
||||||
|
*/
|
||||||
|
bool session_has_username(const Session* sess);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* session_get_id
|
||||||
|
* Fetch the session ID.
|
||||||
|
*
|
||||||
|
* @param sess The session to query.
|
||||||
|
* @return The session's unique ID.
|
||||||
|
*/
|
||||||
|
uint64_t session_get_id(const Session* sess);
|
||||||
|
|
||||||
|
#endif // SESSION_H
|
||||||
8332
server/src/include/yyjson.h
Normal file
8332
server/src/include/yyjson.h
Normal file
File diff suppressed because it is too large
Load Diff
52
server/src/main.c
Normal file
52
server/src/main.c
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#include "include/chat.h"
|
||||||
|
#include "include/session.h"
|
||||||
|
|
||||||
|
#include <libwebsockets.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static volatile sig_atomic_t interrupted = 0;
|
||||||
|
|
||||||
|
static void handle_sigint(int sig) {
|
||||||
|
(void)sig;
|
||||||
|
interrupted = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct lws_protocols protocols[] = {
|
||||||
|
{
|
||||||
|
"coms",
|
||||||
|
cb_chat,
|
||||||
|
sizeof(Session*),
|
||||||
|
1024,
|
||||||
|
},
|
||||||
|
{NULL, NULL, 0, 0}
|
||||||
|
};
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
signal(SIGINT, handle_sigint);
|
||||||
|
|
||||||
|
// Create libws context.
|
||||||
|
struct lws_context_creation_info info;
|
||||||
|
memset(&info, 0, sizeof(info));
|
||||||
|
info.port = 8080;
|
||||||
|
info.protocols = protocols;
|
||||||
|
info.gid = -1;
|
||||||
|
info.uid = -1;
|
||||||
|
|
||||||
|
struct lws_context* context = lws_create_context(&info);
|
||||||
|
if (!context) {
|
||||||
|
fprintf(stderr, "Failed to create LWS context\n");
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
printf("COMS server started on port %d\n", info.port);
|
||||||
|
|
||||||
|
// Service loop.
|
||||||
|
while (!interrupted) { lws_service(context, 1000); }
|
||||||
|
|
||||||
|
// Cleanse.
|
||||||
|
lws_context_destroy(context);
|
||||||
|
printf("Server shutting down.\n");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
58
server/src/session.c
Normal file
58
server/src/session.c
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#include "include/session.h"
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static uint64_t next_session_id = 1;
|
||||||
|
|
||||||
|
// Head of session linked list.
|
||||||
|
static Session* head = NULL;
|
||||||
|
|
||||||
|
Session* session_create(struct lws* wsi) {
|
||||||
|
Session* sess = malloc(sizeof(Session));
|
||||||
|
if (!sess) { return NULL; }
|
||||||
|
sess->wsi = wsi;
|
||||||
|
sess->id = next_session_id++;
|
||||||
|
sess->named = false;
|
||||||
|
sess->name[0] = '\0';
|
||||||
|
sess->next = head;
|
||||||
|
head = sess;
|
||||||
|
return sess;
|
||||||
|
}
|
||||||
|
|
||||||
|
void session_destroy(Session* sess) {
|
||||||
|
if (!sess) { return; }
|
||||||
|
Session** ptr = &head;
|
||||||
|
while (*ptr) {
|
||||||
|
if (*ptr == sess) {
|
||||||
|
*ptr = sess->next;
|
||||||
|
free(sess);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ptr = &(*ptr)->next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Session* session_get_head(void) { return head; }
|
||||||
|
|
||||||
|
void session_broadcast(void (*cb)(Session* s, void* user), void* user) {
|
||||||
|
for (Session* iter = head; iter; iter = iter->next) { cb(iter, user); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void session_set_username(Session* sess, const char* username) {
|
||||||
|
if (!sess || !username) { return; }
|
||||||
|
strncpy(sess->name, username, SESSION_USERNAME_MAX_LEN - 1);
|
||||||
|
sess->name[SESSION_USERNAME_MAX_LEN - 1] = '\0';
|
||||||
|
sess->named = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* session_get_username(const Session* sess) {
|
||||||
|
if (!sess) { return NULL; }
|
||||||
|
return sess->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool session_has_username(const Session* sess) {
|
||||||
|
return sess ? sess->named : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t session_get_id(const Session* sess) { return sess ? sess->id : 0; }
|
||||||
11210
server/src/yyjson.c
Normal file
11210
server/src/yyjson.c
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user