From 9b6310314850de96197c34d7c926ce99cd97ee4b Mon Sep 17 00:00:00 2001 From: lklynet Date: Thu, 8 Jan 2026 15:58:37 -0500 Subject: [PATCH] feat(chat): add sound effects and timestamp toggle functionality - Implement SoundManager for playing sound effects on message events - Add /sound and /timestamp commands to toggle features - Persist sound and timestamp preferences in localStorage - Style timestamps in chat messages --- README.md | 2 + public/app.js | 43 +++++++++++++++++ public/index.html | 1 + public/js/commands.js | 26 +++++++++++ public/js/sound-manager.js | 96 ++++++++++++++++++++++++++++++++++++++ public/style.css | 11 +++++ 6 files changed, 179 insertions(+) create mode 100644 public/js/sound-manager.js diff --git a/README.md b/README.md index 68001d6..80f41a0 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ Open `http://localhost:3000`. The dashboard updates in **Realtime** via Server-S * `/local ` - Send message only to direct connections. * `/whisper ` - Send a private message. * `/block ` - Block a user. +* `/timestamp` - Toggle message timestamps. +* `/sound` - Toggle sound effects for sent/received messages. * **Easter Eggs:** `/shrug`, `/tableflip`, `/heart`, and more. --- diff --git a/public/app.js b/public/app.js index 5c53da5..2d219fd 100644 --- a/public/app.js +++ b/public/app.js @@ -281,9 +281,26 @@ const promptEl = document.querySelector(".prompt"); let myId = null; let myChatHistory = []; let globalChatEnabled = true; +let showTimestamp = localStorage.getItem("showTimestamp") === "true"; let blockedUsers = new Set( JSON.parse(localStorage.getItem("blockedUsers") || "[]") ); + +if (showTimestamp) { + terminalOutput.classList.add("show-timestamps"); +} + +window.toggleTimestamp = () => { + showTimestamp = !showTimestamp; + localStorage.setItem("showTimestamp", showTimestamp); + if (showTimestamp) { + terminalOutput.classList.add("show-timestamps"); + systemStatusBar.innerText = "[SYSTEM] Timestamps enabled"; + } else { + terminalOutput.classList.remove("show-timestamps"); + systemStatusBar.innerText = "[SYSTEM] Timestamps disabled"; + } +}; let nameToId = new Map(); // Context Menu Logic @@ -474,6 +491,17 @@ const appendMessage = (msg) => { let scopeLabel = msg.scope === "LOCAL" ? "[LOCAL] " : ""; if (msg.target) scopeLabel = "[WHISPER] "; + const timestampSpan = document.createElement("span"); + timestampSpan.className = "timestamp"; + const date = new Date(); + timestampSpan.innerText = `[${date + .getHours() + .toString() + .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date + .getSeconds() + .toString() + .padStart(2, "0")}]`; + const senderSpan = document.createElement("span"); senderSpan.className = "msg-sender"; senderSpan.style.color = senderColor; @@ -504,6 +532,7 @@ const appendMessage = (msg) => { contentSpan.style.opacity = "0.8"; } + div.appendChild(timestampSpan); div.appendChild(senderSpan); div.appendChild(contentSpan); } @@ -513,6 +542,11 @@ const appendMessage = (msg) => { }; terminalInput.addEventListener("keypress", async (e) => { + // Init audio context on first interaction + if (window.SoundManager) { + window.SoundManager.init(); + } + if (e.key === "Enter") { let content = terminalInput.value.trim(); if (!content) return; @@ -610,6 +644,7 @@ terminalInput.addEventListener("keypress", async (e) => { }); if (res.ok) { + if (window.SoundManager) window.SoundManager.playSent(); myChatHistory.push(Date.now()); updatePromptStatus(); } else if (res.status === 429) { @@ -637,6 +672,14 @@ evtSource.onmessage = (event) => { } if (data.type === "CHAT") { + // Play sounds + if (window.SoundManager && data.sender !== myId) { + if (data.target === myId) { + window.SoundManager.playWhisper(); + } else { + window.SoundManager.playReceived(); + } + } appendMessage(data); return; } diff --git a/public/index.html b/public/index.html index 869913b..a857109 100644 --- a/public/index.html +++ b/public/index.html @@ -28,6 +28,7 @@ + diff --git a/public/js/commands.js b/public/js/commands.js index cd5be4a..cde01a8 100644 --- a/public/js/commands.js +++ b/public/js/commands.js @@ -10,6 +10,30 @@ const ChatCommands = { if (output) output.innerHTML = ""; }, }, + "/timestamp": { + description: "Toggles timestamps on and off", + execute: () => { + if (window.toggleTimestamp) { + window.toggleTimestamp(); + } + }, + }, + "/sound": { + description: "Toggles sound effects", + execute: () => { + if (window.SoundManager) { + const enabled = window.SoundManager.toggle(); + const status = enabled ? "enabled" : "disabled"; + const output = document.getElementById("terminal-output"); + if (output) { + const div = document.createElement("div"); + div.innerHTML = `[SYSTEM] Sound effects ${status}`; + output.appendChild(div); + output.scrollTop = output.scrollHeight; + } + } + }, + }, "/help": { description: "Shows available commands", execute: () => { @@ -57,6 +81,8 @@ const ChatCommands = { desc: "Send message to direct peers only (Global by default)", }, { cmd: "/clear", desc: "Clear chat history" }, + { cmd: "/timestamp", desc: "Toggle timestamps" }, + { cmd: "/sound", desc: "Toggle sound effects" }, { cmd: "/help", desc: "Show this help menu" }, ]; helpContainer.appendChild( diff --git a/public/js/sound-manager.js b/public/js/sound-manager.js new file mode 100644 index 0000000..c19e639 --- /dev/null +++ b/public/js/sound-manager.js @@ -0,0 +1,96 @@ + +class SoundManager { + constructor() { + this.ctx = null; + this.enabled = localStorage.getItem("soundEnabled") === "true"; + } + + init() { + if (!this.ctx) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (AudioContext) { + this.ctx = new AudioContext(); + } + } + // Resume context if it's suspended (browsers auto-suspend) + if (this.ctx && this.ctx.state === "suspended") { + this.ctx.resume(); + } + } + + toggle() { + this.enabled = !this.enabled; + localStorage.setItem("soundEnabled", this.enabled); + return this.enabled; + } + + // Helper to play a tone + playTone(freq, type, duration, volume = 0.1) { + if (!this.enabled) return; + this.init(); + if (!this.ctx) return; + + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.type = type; + osc.frequency.setValueAtTime(freq, this.ctx.currentTime); + + gain.gain.setValueAtTime(volume, this.ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); + + osc.connect(gain); + gain.connect(this.ctx.destination); + + osc.start(); + osc.stop(this.ctx.currentTime + duration); + } + + playSent() { + // Satisfying "pop" or "click" for sending + // Sine wave starting at 600Hz dropping fast + if (!this.enabled) return; + this.playTone(600, "sine", 0.15, 0.1); + } + + playReceived() { + // Soft "bloop" for receiving + // Sine wave starting at 400Hz + if (!this.enabled) return; + this.playTone(400, "sine", 0.15, 0.1); + } + + playWhisper() { + // Distinct "ding" for whispers + // Two tones slightly separated + if (!this.enabled) return; + this.init(); + if (!this.ctx) return; + + const now = this.ctx.currentTime; + + // Tone 1 + const osc1 = this.ctx.createOscillator(); + const gain1 = this.ctx.createGain(); + osc1.frequency.setValueAtTime(800, now); + gain1.gain.setValueAtTime(0.1, now); + gain1.gain.exponentialRampToValueAtTime(0.01, now + 0.3); + osc1.connect(gain1); + gain1.connect(this.ctx.destination); + osc1.start(now); + osc1.stop(now + 0.3); + + // Tone 2 (higher) + const osc2 = this.ctx.createOscillator(); + const gain2 = this.ctx.createGain(); + osc2.frequency.setValueAtTime(1200, now + 0.1); + gain2.gain.setValueAtTime(0.1, now + 0.1); + gain2.gain.exponentialRampToValueAtTime(0.01, now + 0.4); + osc2.connect(gain2); + gain2.connect(this.ctx.destination); + osc2.start(now + 0.1); + osc2.stop(now + 0.4); + } +} + +window.SoundManager = new SoundManager(); diff --git a/public/style.css b/public/style.css index 7577787..375a141 100644 --- a/public/style.css +++ b/public/style.css @@ -338,6 +338,17 @@ a { color: var(--color-text-anchor-link); text-decoration: none; border-bottom: color: var(--color-terminal-output-message); } +.timestamp { + display: none; + color: #888; + font-size: 0.8em; + margin-right: 5px; +} + +.show-timestamps .timestamp { + display: inline; +} + /* Context Menu */ .context-menu { display: none;