/**
* @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();
});