Current section
Files
Jump to
Current section
Files
priv/static/chat.js
/** * @license * Copyright (c) 2025 Denis Khorkin * * Licensed under the BSD 3-Clause "New" or "Revised" License; * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://opensource.org/license/bsd-3-clause * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *//** * MIG Chat Application - client-side chat implementation * * @namespace MIGChat * @property {object} Config - Configuration constants * @property {object} DOM - DOM element references * @property {object} State - Application state management * @property {object} mockServer - Mock server implementation * @property {object} mockWs - Mock WebSocket connection * @property {object} messageHandlers - Message type handlers * @property {object} commands - Chat command registry * @property {object} CoreFunctions - Core application functions * @property {object} UIRendering - UI rendering functions * @property {object} UserFeedback - User notification system * @property {object} CommandHandlers - Command handlers * @property {object} ConnectionManager - Connection management * @property {object} EventHandlers - User interaction handlers */// --- mockServer ---/** * @typedef {Object} ConnectionResult * @property {boolean} success - Operation status * @property {string} [message] - Optional error message *//** * @typedef {Object} JoinResult * @property {boolean} success - Operation status * @property {string} [message] - Optional error message * @property {string[]} users - List of users in room * @property {boolean} wasAlreadyInRoom - Was user already in room *//** * @typedef {Object} LeaveRoomResult * @property {boolean} success - Operation status * @property {string} [message] - Optional error message * @property {string[]} users - Remaining users in room * @property {boolean} roomDeleted - Whether room was deleted * @property {boolean} leftCurrent - Whether left current room * @property {boolean} disconnected - Whether user fully disconnected *//** * @typedef {Object} RoomInfo * @property {string} name - Room name with # prefix * @property {number} userCount - Number of users in room * @property {boolean} isCurrent - Whether this is current room *//** * @typedef {Object} UserRoomsResult * @property {boolean} success - Operation status * @property {RoomInfo[]} rooms - List of rooms with metadata */// --- mockWs ---/** * @typedef {Object} ConnectErrorResponse * @property {"connect_error"} type - Error response type identifier * @property {string} message - Error description message *//** * @typedef {Object} ConnectAckResponse * @property {"connect_ack"} type - Success response type identifier * @property {string} user - Connected username * @property {string} room - Initial room * @property {string[]} users - Users in initial room * @property {string[]} rooms - All available rooms *//** * @typedef {ConnectErrorResponse|ConnectAckResponse} ConnectResponse *//** * @typedef {Object} JoinErrorResponse * @property {"join_error"} type - Error response type identifier * @property {string} message - Error description message *//** * @typedef {Object} JoinAckResponse * @property {"join_ack"} type - Success response type identifier * @property {string} user - Connected username * @property {string} room - Room name (with # prefix) * @property {string[]} users - Array of users in the room * @property {boolean} wasAlreadyInRoom - Flag if user was already in room * @property {string[]} allRooms - List of all available rooms *//** * @typedef {JoinErrorResponse|JoinAckResponse} JoinResponse *//** * @typedef {Object} PartErrorResponse * @property {"part_error"} type - Error response type identifier * @property {string} message - Error description message *//** * @typedef {Object} PartAckResponse * @property {"part_ack"} type - Success response type identifier * @property {string} user - Username who left * @property {string} room - Room name that was left * @property {string[]} users - Remaining users in room (empty if room deleted) * @property {boolean} leftCurrent - Whether user left their current room * @property {boolean} roomDeleted - Whether room was deleted (no users left) * @property {boolean} disconnected - Whether user fully disconnected (left all rooms) *//** * @typedef {PartErrorResponse|PartAckResponse} PartResponse *//** * @typedef {Object} SendErrorResponse * @property {"send_error"} type - Error response type identifier * @property {string} message - Error description message * @property {string} [originalText] - Original message content *//** * @typedef {Object} SendAckResponse * @property {"send_ack"} type - Success response type identifier * @property {string} user - Sender username * @property {string} text - Acknowledged message *//** * @typedef {SendAckResponse|SendErrorResponse} SendResponse *//** * @typedef {Object} QuitErrorResponse * @property {"quit_error"} type - Error response type identifier * @property {string} message - Error description message *//** * @typedef {Object} QuitAckResponse * @property {"quit_ack"} type - Success response type identifier *//** * @typedef {QuitErrorResponse|QuitAckResponse} QuitResponse *//** * @typedef {Object} MyRoomsErrorResponse * @property {"myrooms_error"} type - Error response type identifier * @property {string} message - Error description message *//** * @typedef {Object} MyRoomsAckResponse * @property {"myrooms_ack"} type - Success response type identifier * @property {boolean} success - Operation status * @property {RoomInfo[]} rooms - List of rooms with metadata *//** * @typedef {MyRoomsErrorResponse|MyRoomsAckResponse} MyRoomsResponse */$(document).ready(function() { /** * Core configuration constants * @namespace Config * @memberof MIGChat */ // Length in UTF-16 units (1-2 per char) const USERNAME_LIMITS = { MAX: 32 }; const ROOMNAME_LIMITS = { MIN: 2, MAX: 50 }; const MESSAGE_LIMITS = { MAX: 400 }; /** * DOM element references * @namespace DOM * @memberof MIGChat */ const $messageArea = $('#message-area'); const $statusLine = $('#status-line'); const $commandLine = $('#command-line'); const $usersList = $('#users'); /** * Application state management * @namespace State * @memberof MIGChat */ let ws = null; let currentUser = null; let currentRoom = null; /** * Mock server implementation * @namespace mockServer * @memberof MIGChat * @property {Object.<string, string[]>} rooms - Rooms data where: * key: room name (with '#' prefix), * value: array of connected users * @property {Set<string>} users - Connected users */ const mockServer = { rooms: { '#general': [] }, users: new Set(), /** * Validate and connect user * @memberof mockServer * @param {string} username - Username to connect * @returns {ConnectionResult} Connection result */ connectUser: function(username) { if (username.length > USERNAME_LIMITS.MAX) { return { success: false, message: `Username too long (max ${USERNAME_LIMITS.MAX} chars)` }; } /* Username must be unique across all connections. * Note: Mock only checks within current tab. */ if (this.users.has(username)) { return { success: false, message: "Username already taken" }; } return { success: true }; }, /** * Join user to room (creates room if not exists) * @memberof mockServer * @param {string} user - Username * @param {string} room - Room name (with # prefix) * @returns {JoinResult} Join operation result */ joinRoom: function(user, room) { if (room.length > ROOMNAME_LIMITS.MAX) { return { success: false, message: "Room name too long (max 50 chars)", users: [] }; } if (!this.rooms[room]) this.rooms[room] = []; const wasAlreadyInRoom = this.rooms[room].includes(user); if (!wasAlreadyInRoom) { this.rooms[room].push(user); } return { success: true, users: [...this.rooms[room]], wasAlreadyInRoom: wasAlreadyInRoom }; }, /** * Remove user from room (deletes room if empty) * @memberof mockServer * @param {string} user - Username * @param {string} room - Room name (with # prefix) * @returns {LeaveRoomResult} Leave operation result */ leaveRoom: function(user, room) { if (!room) { return { success: false, message: "No room specified" // Status: Inactive }; } if (room.length > ROOMNAME_LIMITS.MAX) { return { success: false, message: "Room name too long (max 50 chars)" }; } if (!this.rooms[room]) { return { success: false, message: `Room ${room} not found` }; } const leftCurrent = (room === currentRoom); let roomDeleted = false; this.rooms[room] = this.rooms[room].filter(u => u !== user); const usersLeft = this.rooms[room].length; if (usersLeft === 0 && room !== '#general') { delete this.rooms[room]; roomDeleted = true; } const userHasNoRooms = !Object.values(this.rooms) .some(roomUsers => roomUsers.includes(user)); if (userHasNoRooms) { this.users.delete(user); } return { success: true, users: roomDeleted ? [] : [...this.rooms[room]], roomDeleted: roomDeleted, leftCurrent: leftCurrent, disconnected: userHasNoRooms }; }, /** * Get rooms where user is present * @memberof mockServer * @param {string} user - Username * @returns {UserRoomsResult} User rooms information */ getMyRooms: function(user) { const userRooms = Object.keys(this.rooms) .filter(room => this.rooms[room].includes(user)); return { success: true, rooms: userRooms.map(room => ({ name: room, userCount: this.rooms[room].length, isCurrent: (room === currentRoom) })) }; } }; /** * WebSocket mock implementation with full connection flow handling * * @namespace mockWs * @memberof MIGChat * @description Handles all WebSocket communication patterns including: * * ### Connection Flow: * 1. Connect Sequence: * - Client -> connect * - Server -> connect_ack * - On failure: Server -> connect_error * * ### Normal Quit Flow: * 1. Client -> quit * 2. Server -> quit_ack * 3. Server -> close(1000) * 4. Client(onclose) * * ### Error Cases: * - Command Errors (server rejects): * - connect_error: Invalid username * - quit_error: User not connected * - Connection Errors: * - onclose: codes 1001-1015 (network issues) * - system_error: internal server errors * * ### Standard Close Codes: * - 1000: Normal closure * - 1001: Going away * - 1006: Abnormal closure * - 4000-4999: Application-specific codes * * @see {@link https://datatracker.ietf.org/doc/html/rfc6455#section-7.4 * RFC 6455 Section 7.4} */ const mockWs = { /** * Handle connect request from client * @memberof MIGChat.mockWs * @private * @param {object} msg - Connection message * @param {string} msg.user - Username to connect * @returns {ConnectResponse} Connection response */ _handleConnect(msg) { console.log(`[MOCK] Processing connect for: ${msg.user}`); if (currentUser && currentUser !== msg.user) { this._handleQuit({ user: currentUser }); } const response = mockServer.connectUser(msg.user); if (!response.success) { console.log(`[MOCK] Reject: ${msg.user} (${response.message})`); return { type: "connect_error", message: response.message }; } mockServer.users.add(msg.user); const joinResponse = mockServer.joinRoom(msg.user, '#general'); console.log(`[MOCK] User ${msg.user} connected to #general`); return { type: "connect_ack", user: msg.user, room: '#general', // Users list in #general users: joinResponse.users, // All available rooms rooms: Object.keys(mockServer.rooms) }; }, /** * Handle join room request * @memberof MIGChat.mockWs * @private * @param {object} msg - Join message * @param {string} msg.user - Username * @param {string} msg.room - Room name (with # prefix) * @returns {JoinResponse} Join response */ _handleJoin(msg) { console.log(`[MOCK] Processing join: ${msg.user} -> ${msg.room}`); const response = mockServer.joinRoom(msg.user, msg.room); if (!response.success) { console.log(`[MOCK] Reject join: ${response.message}`); return { type: "join_error", message: response.message }; } const action = response.wasAlreadyInRoom ? "switched to" : "joined"; console.log(`[MOCK] ${msg.user} ${action} ${msg.room}`); return { type: "join_ack", user: msg.user, room: msg.room, users: response.users, wasAlreadyInRoom: response.wasAlreadyInRoom, allRooms: Object.keys(mockServer.rooms) }; }, /** * Handle leave room request * @memberof MIGChat.mockWs * @private * @param {object} msg - Leave message * @param {string} msg.user - Username * @param {string} msg.room - Room name (with # prefix) * @returns {PartResponse} Leave operation response */ _handlePart(msg) { console.log(`[MOCK] Processing part: ${msg.user} <- ${msg.room}`); const response = mockServer.leaveRoom(msg.user, msg.room); if (!response.success) { console.log(`[MOCK] Part error: ${response.message}`); return { type: "part_error", message: response.message }; } console.log(`[MOCK] User ${msg.user} left ${msg.room}`); if (response.roomDeleted) { console.log(`[MOCK] Room ${msg.room} deleted (no users left)`); } return { type: "part_ack", user: msg.user, room: msg.room, users: response.users, leftCurrent: response.leftCurrent, roomDeleted: response.roomDeleted, disconnected: response.disconnected }; }, /** * Handle message sending * @memberof MIGChat.mockWs * @private * @param {object} msg - Message object * @param {string} msg.user - Sender username * @param {string} msg.room - Target room * @param {string} msg.text - Message text * @returns {SendResponse} Message operation result */ _handleSend(msg) { console.log(`[MOCK] Send: ${msg.user}@${msg.room}: "${msg.text}"`); if (msg.text.length > MESSAGE_LIMITS.MAX) { return { type: "send_error", message: `Message too long (max ${MESSAGE_LIMITS.MAX} chars)`, originalText: msg.text }; } return { type: "send_ack", user: msg.user, text: msg.text }; }, /** * Handle quit request * @memberof MIGChat.mockWs * @private * @param {object} msg - Quit message * @param {string} msg.user - Username to disconnect * @returns {QuitResponse} Quit operation response */ _handleQuit(msg) { console.log(`[MOCK] Processing quit for: ${msg.user}`); if (!mockServer.users.has(msg.user)) { return { type: "quit_error", message: "User not found" }; } // Cleans up server state mockServer.users.delete(msg.user); for (const room in mockServer.rooms) { mockServer.leaveRoom(msg.user, room); } return { type: "quit_ack" }; // Connection close will be handled in send() }, /** * Handle rooms list request * @memberof MIGChat.mockWs * @private * @param {object} msg - Request message * @param {string} msg.user - Username * @returns {MyRoomsResponse} Rooms list response */ _handleGetMyRooms(msg) { console.log(`[MOCK] Request rooms for: ${msg.user}`); const response = mockServer.getMyRooms(msg.user); if (!response.success) { console.log(`[MOCK] Error getting rooms: ${response.message}`); } return { type: response.success ? "myrooms_ack" : "myrooms_error", ...response }; }, /** * Simulate WebSocket connection opening * @memberof MIGChat.mockWs */ open() { console.log("[MOCK] Connection opened"); if (this.onopen) { setTimeout(() => this.onopen(), 0); // Async call } }, /** * Mock server-initiated connection close * - Triggers onclose event * - Uses standard WebSocket close codes * @memberof MIGChat.mockWs * @param {number} [code=1000] - WebSocket close code * @param {string} [reason="Server closed connection"] - Close reason */ close(code = 1000, reason = "Server closed connection") { console.log(`[MOCK] Closing with code ${code}: ${reason}`); if (this.onclose) { this.onclose({ wasClean: code === 1000, code: code, reason: reason }); } }, /** * Process incoming WebSocket message * @memberof MIGChat.mockWs * @param {string} data - JSON string message * @throws {Error} When message processing fails */ send(data) { const msg = JSON.parse(data); console.log("[MOCK] WS received:", msg); setTimeout(() => { if (!this.onmessage) { console.warn("[MOCK] No message handler registered!"); return; } try { let response; switch (msg.type) { case "connect": response = this._handleConnect(msg); break; case "join": case "create": response = this._handleJoin(msg); break; case "part": response = this._handlePart(msg); break; case "send": response = this._handleSend(msg); // Send broadcast messages mockServer.rooms[msg.room]?.forEach(user => { this.onmessage({ data: JSON.stringify(response) }); }); return; case "quit": response = this._handleQuit(msg); if (response) { // Send acknowledgement (ACK) this.onmessage({ data: JSON.stringify(response) }); // Close connection after short delay setTimeout(() => this.close(), 100); } return; case "get_myrooms": response = this._handleGetMyRooms(msg); break; default: console.warn(`[MOCK] Unknown message type: ${msg.type}`); return; } if (response) { this.onmessage({ data: JSON.stringify(response) }); } } catch (error) { console.error("[MOCK] Processing error:", error); this.onmessage({ data: JSON.stringify({ type: "system_error", message: "Internal server error" }) }); } }, 300); } }; /** * WebSocket event handlers and message processing * @namespace WebSocketHandlers * @memberof MIGChat */ // --- mockWs.onopen() --- /** * Handles successful WebSocket connection establishment * @memberof MIGChat.WebSocketHandlers * @function onopen */ mockWs.onopen = function() { console.log("[MOCK] Connection established"); }; // --- mockWs.onclose(event) --- /** * Handles WebSocket connection closure * @memberof MIGChat.WebSocketHandlers * @function onclose * @param {CloseEvent} event - Close event details * @property {number} event.code - WebSocket close code * @property {string} event.reason - Close reason * @property {boolean} event.wasClean - True if clean closure */ mockWs.onclose = function(event) { if (event.code !== 1000) { console.warn("[MOCK] Abnormal connection close:", { code: event.code, reason: event.reason, wasClean: event.wasClean }); } console.log("[MOCK] Connection closed by server", event); // Clean up client state currentUser = null; currentRoom = null; ws = null; // Generate detailed closure message let message = 'Disconnected: '; message += event.code === 1000 ? 'Normal closure' : `Error (code ${event.code})`; if (event.reason) message += `: ${event.reason}` // Update UI updateStatus(); updateRoomsUI(); updateUsersUI([]); showSystemMessage(message); }; // --- Message Handlers --- /** * Message handlers for different WebSocket message types * @namespace MessageHandlers * @memberof MIGChat */ const messageHandlers = { /** * Handle successful connection acknowledgement * @memberof MIGChat.MessageHandlers * @param {object} data - Response data * @param {string} data.user - Connected username * @param {string} data.room - Initial room * @param {string[]} data.users - Users in initial room */ handleConnectAck(data) { currentUser = data.user; currentRoom = data.room; updateStatus(); updateRoomsUI(); updateUsersUI(data.users); showSystemMessage(`Connected as ${data.user}`); }, /** * Handle connection error * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handleConnectError(error) { showErrorInInput(`Connection failed: ${error.message}`); console.error('[MOCK] Connection error:', error.message); }, /** * Handle successful room join * @memberof MIGChat.MessageHandlers * @param {object} data - Response data * @param {string} data.room - Joined room * @param {string[]} data.users - Users in room * @param {boolean} data.wasAlreadyInRoom - True if user was already in room */ handleJoinAck(data) { currentRoom = data.room; updateStatus(); updateRoomsUI(); updateUsersUI(data.users); showSystemMessage(data.wasAlreadyInRoom ? `Switched to ${data.room}` : `Joined ${data.room}`); }, /** * Handle room join error * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handleJoinError(error) { showErrorInInput(`Can't join room: ${error.message}`); console.error('[MOCK] Join error:', error.message); }, /** * Handle successful room leave acknowledgement * @memberof MIGChat.MessageHandlers * @param {object} data - Response data * @param {boolean} data.leftCurrent - True if left current room * @param {boolean} data.disconnected - True if user disconnected completely * @param {string} data.room - Room name that was left * @param {string[]} [data.users] - Remaining users in room (if not deleted) */ handlePartAck(data) { if (data.leftCurrent) currentRoom = null; if (data.disconnected) { currentUser = null; showSystemMessage("Disconnected (no rooms left)"); } else { showSystemMessage(`Left ${data.room}`); } updateStatus(); updateRoomsUI(); updateUsersUI(data.users || []); // (empty array if undefined) }, /** * Handle room leave error * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handlePartError(error) { showErrorInInput(`Can't leave room: ${error.message}`); console.error('[MOCK] Part error:', error.message); }, /** * Handle successful message delivery * @memberof MIGChat.MessageHandlers * @param {object} data - Message data * @param {string} data.user - Sender username * @param {string} data.text - Message content */ handleSendAck(data) { showUserMessage(data.user, data.text); }, /** * Handle message send error * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handleSendError(error) { showErrorInInput(`Message not sent: ${error.message}`); console.error('[MOCK] Send error:', error.message); }, /** * Handle successful quit acknowledgement * @memberof MIGChat.MessageHandlers * @description Note: Actual cleanup happens in onclose handler */ handleQuitAck() { console.log('[MOCK] Quit acknowledged by server'); }, /** * Handle quit command error * - Called when server explicitly rejects quit request * - Different from connection close errors * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handleQuitError(error) { console.error('[MOCK] Quit error:', error.message); showSystemMessage(`Quit failed: ${error.message}`); updateStatus(); // Reflect possible partial state changes }, /** * Handle successful rooms list response * @memberof MIGChat.MessageHandlers * @param {object} data - Response data * @param {Array<{ * name: string, * userCount: number, * isCurrent: boolean * }>} data.rooms - List of rooms with metadata */ handleMyRoomsAck(data) { if (data.rooms.length === 0) { showSystemMessage("Not in any rooms. Use :join #room"); return; } const roomList = data.rooms.map(room => `${room.isCurrent ? '> ' : '- '}${room.name} ` + `(${room.userCount} user${room.userCount !== 1 ? 's' : ''})` + `${room.isCurrent ? ' [CURRENT]' : ''}` ).join('\n'); $messageArea.append(` <div class="system rooms-info"> <strong>${currentUser}'s rooms (${data.rooms.length}):</strong> <pre>${roomList}</pre> </div> `); $messageArea.scrollTop($messageArea[0].scrollHeight); }, /** * Handle rooms list error * @memberof MIGChat.MessageHandlers * @param {object} error - Error details * @param {string} error.message - Error description */ handleMyRoomsError(error) { showSystemMessage(`Can't fetch rooms: ${error.message}`); console.error('[MOCK] Rooms error:', error.message); } }; // --- mockWs.onmessage(event) --- /** * Main WebSocket message dispatcher * @memberof MIGChat.WebSocketHandlers * @function onmessage * @param {MessageEvent} event - Incoming message event * @param {string} event.data - Message data (JSON string) */ mockWs.onmessage = function(event) { const data = JSON.parse(event.data); console.log('[MOCK] Received:', data.type, data); try { switch(data.type) { case "connect_ack": messageHandlers.handleConnectAck(data); break; case "connect_error": messageHandlers.handleConnectError(data); break; case "join_ack": messageHandlers.handleJoinAck(data); break; case "join_error": messageHandlers.handleJoinError(data); break; case "part_ack": messageHandlers.handlePartAck(data); break; case "part_error": messageHandlers.handlePartError(data); break; case "send_ack": messageHandlers.handleSendAck(data); break; case "send_error": messageHandlers.handleSendError(data); break; case "quit_ack": messageHandlers.handleQuitAck(); break; case "quit_error": messageHandlers.handleQuitError(data); break; case "myrooms_ack": messageHandlers.handleMyRoomsAck(data); break; case "myrooms_error": messageHandlers.handleMyRoomsError(data); break; default: console.warn("Unknown message type:", data.type); showSystemMessage(`Server sent unknown message: ${data.type}`); } } catch (error) { console.error('Message handling failed:', error); showErrorInInput('Internal client error'); } }; /** * Command registry for chat application (case-independent) * * @namespace commands * @memberof MIGChat * @property {object} core - Essential chat commands * @property {object} service - Additional utility commands * @property {object} aliases - Shortcuts for core commands (first letter) */ const commands = { /** * Core chat commands * @namespace core * @memberof MIGChat.commands */ core: { /** * Connect to chat with username * @memberof MIGChat.commands.core * @param {string} username - Unique identifier */ connect: { args: { min: 1, max: 1 }, handler: handleConnect }, /** * Join existing room or create new * @memberof MIGChat.commands.core * @param {string} room - Room name starting with # */ join: { args: { min: 1, max: 1 }, handler: handleJoin }, /** * Leave current or specified room * @memberof MIGChat.commands.core * @param {string} [room] - Optional room name */ part: { args: { min: 0, max: 1 }, handler: handleLeave }, /** * Send message to current room * @memberof MIGChat.commands.core * @param {...string} message - Text to send */ send: { args: { min: 1, max: Infinity }, handler: handleSend }, /** * Disconnect from chat * @memberof MIGChat.commands.core */ quit: { args: { min: 0, max: 0 }, handler: handleQuit } }, /** * Service commands * @namespace service * @memberof MIGChat.commands */ service: { /** * List all joined rooms * @memberof MIGChat.commands.service */ rooms: { args: { min: 0, max: 0 }, handler: handleRooms } }, /** * Command aliases * @namespace aliases * @memberof MIGChat.commands */ aliases: { /** @alias MIGChat.commands.core.connect */ c: { args: { min: 1, max: 1 }, handler: handleConnect }, /** @alias MIGChat.commands.core.join */ j: { args: { min: 1, max: 1 }, handler: handleJoin }, /** @alias MIGChat.commands.core.part */ p: { args: { min: 0, max: 1 }, handler: handleLeave }, /** @alias MIGChat.commands.core.send */ s: { args: { min: 1, max: Infinity }, handler: handleSend }, /** @alias MIGChat.commands.core.quit */ q: { args: { min: 0, max: 0 }, handler: handleQuit } } }; /** * Core application functionality including command processing and validation * @namespace CoreFunctions * @memberof MIGChat */ /** * Initialize sidebar UI elements and behaviors * @memberof MIGChat.CoreFunctions * @function initSidebar * @returns {void} */ function initSidebar() { updateRoomsUI(); const VSN = "0.1.0"; const $sidebar = $('#sidebar'); const $toggle = $('#sidebar-toggle'); const titleSpan = document.querySelector("#sidebar-header span"); if (titleSpan) { titleSpan.textContent = `${titleSpan.textContent} v${VSN}`; } $toggle.on('click', function() { $sidebar.toggleClass('collapsed'); $toggle.html($sidebar.hasClass('collapsed') ? '»' : '≡'); }); } /** * Unified command accessor * @memberof MIGChat.CoreFunctions * @function getCommand * @param {string} command - Command name or alias * @returns {object|null} Command configuration object or null if not found */ function getCommand(command) { return commands.core[command] || commands.service[command] || commands.aliases[command] || null; } /** * Process chat command and validate arguments * @memberof MIGChat.CoreFunctions * @function processCommand * @param {string} command - Command name (e.g. "join", "send") * @param {string[]} args - Array of command arguments * @throws {Error} When command processing fails */ function processCommand(command, args) { if (!command) { showErrorInInput('Empty command'); return; } const cmd = getCommand(command); if (!cmd) { showErrorInInput(`Unknown command: ${command}`); return; } if (!validateArgCount(cmd, args)) return; const processedArgs = preprocessCommandArgs(command, args); if (!processedArgs) return; executeCommand(cmd, processedArgs); } // --- Validation Helpers --- /** * Check argument count matches command requirements * @memberof MIGChat.CoreFunctions * @function validateArgCount * @param {object} cmd - Command configuration object * @param {string[]} args - Provided arguments array * @returns {boolean} True if argument count is valid */ function validateArgCount(cmd, args) { const isArgCountValid = typeof cmd.args === 'object' ? args.length >= cmd.args.min && args.length <= cmd.args.max : args.length === cmd.args; // BC for numeric args if (!isArgCountValid) { showErrorInInput(`Usage: :${cmd.name}${getArgHint(cmd.args)}`); return false; } return true; } /** * Validate room name format * @memberof MIGChat.CoreFunctions * @function validateRoomArgs * @param {string} roomName - Room name to validate (with # prefix) * @returns {boolean} True if room name is valid */ function validateRoomArgs(roomName) { if (roomName && !roomName.startsWith('#')) { showErrorInInput('Room must start with # (e.g., #general)'); return false; } if (roomName && roomName.length < ROOMNAME_LIMITS.MIN) { showErrorInInput(`Room name too short ` + `(min ${ROOMNAME_LIMITS.MIN} chars)`); return false; } return true; } // --- Processing --- /** * Pre-process command arguments based on command type * @memberof MIGChat.CoreFunctions * @function preprocessCommandArgs * @param {string} command - Command name being processed * @param {string[]} args - Raw arguments array * @returns {string|array|null} Processed arguments or null if validation failed */ function preprocessCommandArgs(command, args) { switch (command) { case 'join': case 'create': case 'part': return validateRoomArgs(args[0]) ? args : null; case 'send': return processMessageArgs(args); default: return args; } } /** * Process message arguments into single line string * @memberof MIGChat.CoreFunctions * @function processMessageArgs * @param {string[]} args - Message parts array * @returns {string|null} Combined message string or null if empty */ function processMessageArgs(args) { const message = args.join(' ').replace(/[\r\n]+/g, ' ').trim(); if (!message) { showErrorInInput('Message cannot be empty'); return null; } return message; } // --- Execution --- /** * Execute validated command with processed arguments * @memberof MIGChat.CoreFunctions * @function executeCommand * @param {object} cmd - Command configuration object * @param {string|string[]} processedArgs - Validated arguments */ function executeCommand(cmd, processedArgs) { cmd.handler(processedArgs); if (!$commandLine.hasClass('error')) { $commandLine.val(''); } } // --- Utils --- /** * Generate argument hint for usage errors * @memberof MIGChat.CoreFunctions * @function getArgHint * @private * @param {object|number} argsDef - Arguments definition (min/max or count) * @returns {string} Usage hint string (e.g. " <arg>", " [arg]") */ function getArgHint(argsDef) { if (typeof argsDef === 'object') { if (argsDef.min === 1 && argsDef.max === 1) return ' <arg>'; if (argsDef.min === 0 && argsDef.max === 1) return ' [arg]'; if (argsDef.max === Infinity) return ' <message...>'; } return argsDef === 1 ? ' <arg>' : ''; } /** * User Interface rendering and update functions * @namespace UIRendering * @memberof MIGChat */ /** * Render and update the rooms list in sidebar * @memberof MIGChat.UIRendering * @function updateRoomsUI * @description Updates the rooms list display including: * - Room names with user counts * - Active room highlighting * - Click handlers for room switching */ function updateRoomsUI() { const $roomsList = $('#rooms'); $roomsList.empty(); for (const room in mockServer.rooms) { const userCount = mockServer.rooms[room].length; const roomItem = $(`<li>${room} (${userCount})</li>`); if (room === currentRoom) { roomItem.addClass('active'); } roomItem.on('click', () => { if (currentUser && room !== currentRoom) { handleJoin([room]); } }); $roomsList.append(roomItem); } } /** * Update the users list display in sidebar * @memberof MIGChat.UIRendering * @function updateUsersUI * @param {string[]} users - Array of usernames to display * @description Clears and repopulates the users list with current room participants */ function updateUsersUI(users) { const $usersList = $('#users'); $usersList.empty(); users.forEach(user => { $usersList.append($('<li>').text(user)); }); } /** * Update the connection status display * @memberof MIGChat.UIRendering * @function updateStatus * @description Shows current connection state including: * - Username * - Current room (if any) * - Room count (when not in active room) * - Connection status text * - Visual connection indicator */ function updateStatus() { const isConnected = currentUser && currentRoom; const parts = []; if (currentUser) parts.push(currentUser); if (currentRoom) parts.push(currentRoom); if (currentUser && !currentRoom) { const userRooms = Object.keys(mockServer.rooms) .filter(room => mockServer.rooms[room].includes(currentUser)); const count = userRooms.length; if (count > 0) { parts.push(`[${count} room${count !== 1 ? 's' : ''}]`); } } parts.push(`Status: ${!currentUser ? 'Disconnected' : (currentRoom ? 'Connected' : 'Inactive')}`); $statusLine.text(parts.join(' | ')) .toggleClass('connected', isConnected); } /** * User feedback and notification system * @namespace UserFeedback * @memberof MIGChat */ /** * Display system notification message in chat area * @memberof MIGChat.UserFeedback * @function showSystemMessage * @param {string} text - System message text to display * @description Appends system message with special styling to message area */ function showSystemMessage(text) { $messageArea.append(`<div class="system">${text}</div>`); } /** * Display user message in chat area with sender formatting * @memberof MIGChat.UserFeedback * @function showUserMessage * @param {string} user - Sender username * @param {string} text - Message content * @description Appends user message with username highlighting */ function showUserMessage(user, text) { $messageArea.append(`<div><strong>${user}:</strong> ${text}</div>`); } /** * Display error message in command input field * @memberof MIGChat.UserFeedback * @function showErrorInInput * @param {string} msg - Error message text * @description Shows error in input field with special error styling */ function showErrorInInput(msg) { $commandLine.val(`Error: ${msg}`).addClass('error'); } /** * Command handlers for chat operations * @namespace CommandHandlers * @memberof MIGChat */ /** * Handle user connection request * @memberof MIGChat.CommandHandlers * @function handleConnect * @param {string[]} username - Array containing single username string * @description Performs user connection with automatic disconnection of previous user * @example * // Connect new user * handleConnect(["newuser"]); */ function handleConnect([username]) { if (currentUser && currentUser !== username) { handleQuit(); // Mock implementation skips quit_ack wait because: // 1. mockWs processes quit synchronously // 2. Server acknowledgement isn't required for workflow // Production code should await quit_ack here } ws = connect(); ws.send(JSON.stringify({ type: "connect", user: username })); } /** * Handle room join/create request * @memberof MIGChat.CommandHandlers * @function handleJoin * @param {string[]} room - Array containing single room name (e.g. ["#general"]) * @description Joins existing room or creates new one if doesn't exist * @example * // Join existing room * handleJoin(["#general"]); * * // Create new room * handleJoin(["#newroom"]); */ function handleJoin([room]) { if (!validateConnection(ws, currentUser)) return; ws.send(JSON.stringify({ type: "join", user: currentUser, room: room })); } /** * Handle room leave request * @memberof MIGChat.CommandHandlers * @function handleLeave * @param {string[]} args - Empty array or array with room name * @description Leaves specified room or current room if not specified * @example * // Leave current room * handleLeave([]); * * // Leave specific room * handleLeave(["#general"]); */ function handleLeave(args) { if (!validateConnection(ws, currentUser)) return; ws.send(JSON.stringify({ type: "part", user: currentUser, // args[0] takes precedence over currentRoom when present room: args[0] || currentRoom })); } /** * Handle message sending * @memberof MIGChat.CommandHandlers * @function handleSend * @param {string} message - Message text to send * @description Sends message to current room with validation * @throws {Error} When not connected or no room joined * @example * // Send regular message * handleSend("Hello everyone!"); */ function handleSend(message) { if (!validateConnection(ws, currentUser)) return; if (!currentRoom) { showErrorInInput('Join a room first: :join <room>'); return; } ws.send(JSON.stringify({ type: "send", user: currentUser, room: currentRoom, text: message })); } /** * Handle user disconnection request * @memberof MIGChat.CommandHandlers * @function handleQuit * @description Initiates graceful disconnection from server * @example * // Disconnect current user * handleQuit(); */ function handleQuit() { if (!validateConnection(ws, currentUser)) return; // Only send quit request, let server initiate close ws.send(JSON.stringify({ type: "quit", user: currentUser })); } /** * Handle rooms list request * @memberof MIGChat.CommandHandlers * @function handleRooms * @description Requests list of rooms the user has joined * @example * // Get rooms list * handleRooms(); */ function handleRooms() { if (!validateConnection(ws, currentUser)) return; ws.send(JSON.stringify({ type: "get_myrooms", user: currentUser })); } /** * WebSocket connection management utilities * @namespace ConnectionManager * @memberof MIGChat */ /** * Establish and manage WebSocket connections * @memberof MIGChat.ConnectionManager * @function connect * @returns {object} Active WebSocket connection instance * @description Handles mock WebSocket connection lifecycle: * - Initializes connection if not exists * - Triggers asynchronous opening process * - Returns connection instance * @example * // Establish connection * const connection = connect(); */ function connect() { if (!ws) { ws = mockWs; ws.open(); console.log("[MOCK] Connection initialized"); } return ws; } /** * Validate connection and authentication state * @memberof MIGChat.ConnectionManager * @function validateConnection * @param {object} connection - WebSocket connection to validate * @param {string|null} user - Current username if authenticated * @returns {boolean} True if connection is valid and user is authenticated * @description Verifies preconditions for chat operations: * - Active WebSocket connection must exist * - User must be authenticated * - Shows appropriate error messages if validation fails * @example * // Check before sending message * if (validateConnection(ws, currentUser)) { * // Safe to proceed * } */ function validateConnection(connection, user) { if (!connection) { showErrorInInput("Not connected to server"); return false; } if (!user) { showErrorInInput("Connect first: :connect <username>"); return false; } return true; } /** * Global event handlers for user interactions * @namespace EventHandlers * @memberof MIGChat */ /** * Colon (:) hotkey handler for command mode * @memberof MIGChat.EventHandlers * @listens keypress * @param {KeyboardEvent} e - Keyboard event * @description Handles colon keypress to: * - Activate command input mode * - Preserve existing input * - Clear error states * - Position cursor appropriately */ $(document).on('keypress', function(e) { if (e.key === ':') { e.preventDefault(); // Prevents automatic insertion ':' if ($commandLine.hasClass('error')) { $commandLine.focus().val(':').removeClass('error'); } else { $commandLine.focus().val(':' + $commandLine.val()); } $commandLine[0].setSelectionRange(1, 1); } }); /** * Command/message submission handler * @memberof MIGChat.EventHandlers * @listens keypress * @param {KeyboardEvent} e - Keyboard event * @description Processes Enter key to: * - Execute commands (starting with :) * - Send regular messages * - Clear input after sending */ $commandLine.on('keypress', function(e) { if (e.which === 13) { // Enter const input = $commandLine.val().trim(); if (!input) return; // Ignore empty input if (input.startsWith(':')) { const cmd = input.slice(1); const parts = cmd.split(' '); const command = parts[0].toLowerCase(); const args = parts.slice(1); //console.log(`DBG: "${command}" with args:`, args); processCommand(command, args); } else { handleSend(input); $commandLine.val(''); // Clear input after sending } } }); /** * Special keyboard shortcuts handler * @memberof MIGChat.EventHandlers * @listens keydown * @param {KeyboardEvent} e - Keyboard event * @description Handles terminal-style shortcuts: * - Ctrl+K: Delete to end of line * - Ctrl+U: Clear entire line * - Ctrl+A: Move to line start * - Ctrl+E: Move to line end */ $commandLine.on('keydown', function(e) { const input = $(this); const val = input.val(); const cursorPos = this.selectionStart; if (e.ctrlKey && e.key === 'k') { e.preventDefault(); const newVal = val.substring(0, cursorPos); input.val(newVal) .removeClass('error'); this.setSelectionRange(cursorPos, cursorPos); return; } if (e.ctrlKey && e.key === 'u') { e.preventDefault(); $commandLine.val('') .removeClass('error') .attr('placeholder', 'Enter a command...'); } if (e.ctrlKey && e.key === 'a') { e.preventDefault(); this.setSelectionRange(0, 0); return; } if (e.ctrlKey && e.key === 'e') { e.preventDefault(); this.setSelectionRange(val.length, val.length); return; } }); // Final initialization $commandLine.val('').removeClass('error'); initSidebar(); updateStatus();});