Skip to content

Commit c6a9981

Browse files
feat: Add User Nickname System with Validation and Edit Functionality (#16)
* feat: Add user nickname system with validation modal - Implemented beautiful welcome modal for nickname input - Added comprehensive client and server-side validation (3-20 chars, alphanumeric+underscore, unique) - Enhanced WebSocket protocol to handle nickname as first message - Updated ConnectionManager to track and validate nicknames - Modified UI to display nicknames instead of numeric IDs - Added join/leave messages with user nicknames and emojis - Maintained 100% backward compatibility with CDC functionality - Fixed bug in consumer.py for unhandled CDC operation types - Added real-time validation feedback and error handling - Implemented smooth animations and mobile-responsive design This feature allows users to personalize their chat identity with validated nicknames, improving user experience while maintaining all existing CDC and chat functionality. * feat: Add localStorage persistence and Change Nickname button - Nickname now persists across page refreshes using localStorage - Added 'Change Nickname' button in header for easy nickname updates - Auto-reconnect with saved nickname on page load - Server validates uniqueness on each connection - Clear localStorage and reconnect when changing nickname - Improved UX: users only set nickname once per browser - Export changeNickname to window for global access * feat: Add user nickname system with validation and edit functionality - Add nickname modal on page load with purple gradient theme - Implement backend nickname validation (2-20 chars, alphanumeric + spaces/underscores/hyphens) - Add uniqueness check to prevent duplicate nicknames - Display nicknames in messages and header instead of client IDs - Add edit nickname button for easy nickname changes - Include real-time character counter and error handling - Maintain existing CDC functionality and UI consistency - Ask for nickname on every page refresh (no persistence) Resolves issue: User Nickname System feature request Hacktoberfest 2025 contribution
1 parent 5f47f4f commit c6a9981

File tree

6 files changed

+425
-17
lines changed

6 files changed

+425
-17
lines changed

app/internal/connection_manager.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,37 @@ def __call__(cls, *args, **kwargs):
1414
class ConnectionManager(metaclass=SingletonMeta):
1515
def __init__(self):
1616
self.active_connections: list[WebSocket] = []
17+
self.nicknames: dict[WebSocket, str] = {} # Store websocket -> nickname mapping
1718

18-
async def connect(self, websocket: WebSocket, client_id: str):
19+
async def connect(self, websocket: WebSocket, client_id: str, nickname: str = None):
1920
await websocket.accept()
2021
self.active_connections.append(websocket)
21-
await self.broadcast(f'Client {client_id} joined the chat')
22+
23+
# Store nickname for this connection
24+
if nickname:
25+
self.nicknames[websocket] = nickname
26+
await self.broadcast(f'🎉 {nickname} joined the chat')
27+
else:
28+
await self.broadcast(f'Client {client_id} joined the chat')
2229

2330
async def disconnect(self, websocket: WebSocket, client_id: str):
31+
nickname = self.nicknames.get(websocket, f'Client {client_id}')
2432
self.active_connections.remove(websocket)
25-
await self.broadcast(f'Client {client_id} left the chat')
33+
34+
# Remove nickname mapping
35+
if websocket in self.nicknames:
36+
del self.nicknames[websocket]
37+
38+
await self.broadcast(f'👋 {nickname} left the chat')
2639

2740
async def broadcast(self, message: str):
2841
for connection in self.active_connections:
2942
await connection.send_text(message)
43+
44+
def is_nickname_taken(self, nickname: str) -> bool:
45+
"""Check if nickname is already in use"""
46+
return nickname.lower() in [n.lower() for n in self.nicknames.values()]
47+
48+
def get_nickname(self, websocket: WebSocket) -> str:
49+
"""Get nickname for a websocket connection"""
50+
return self.nicknames.get(websocket, 'Anonymous')

app/internal/consumer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ async def exec(self):
3636
case 'd':
3737
changes = data.get('payload', {}).get('before', {})
3838
message = f'SuperHero [Deleted]: {changes}'
39+
case _:
40+
continue
3941
await manager.broadcast(message)
4042
except Exception as e:
4143
print(e)

app/routes/websockets.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from fastapi import APIRouter, WebSocket
1+
from fastapi import APIRouter, WebSocket, Query
22
from starlette.websockets import WebSocketDisconnect
3+
import re
34

45
from app.internal.connection_manager import ConnectionManager
56

@@ -9,13 +10,49 @@
910
manager = ConnectionManager()
1011

1112

13+
def validate_nickname(nickname: str) -> tuple[bool, str]:
14+
"""Validate nickname according to rules"""
15+
if not nickname or len(nickname.strip()) == 0:
16+
return False, "Nickname cannot be empty"
17+
18+
nickname = nickname.strip()
19+
20+
if len(nickname) > 20:
21+
return False, "Nickname must be 20 characters or less"
22+
23+
if len(nickname) < 2:
24+
return False, "Nickname must be at least 2 characters"
25+
26+
# Allow alphanumeric, spaces, underscores, and hyphens
27+
if not re.match(r'^[a-zA-Z0-9_ -]+$', nickname):
28+
return False, "Nickname can only contain letters, numbers, spaces, underscores, and hyphens"
29+
30+
if manager.is_nickname_taken(nickname):
31+
return False, "Nickname is already taken"
32+
33+
return True, nickname
34+
35+
1236
@router.websocket('/{client_id}')
13-
async def websocket_endpoint(websocket: WebSocket, client_id: str):
14-
await manager.connect(websocket, client_id)
37+
async def websocket_endpoint(websocket: WebSocket, client_id: str, nickname: str = Query(None)):
38+
# Validate nickname if provided
39+
validated_nickname = None
40+
if nickname:
41+
is_valid, result = validate_nickname(nickname)
42+
if is_valid:
43+
validated_nickname = result
44+
else:
45+
await websocket.close(code=1008, reason=result)
46+
return
47+
48+
await manager.connect(websocket, client_id, validated_nickname)
1549
try:
1650
while True:
1751
data = await websocket.receive_text()
18-
message = f'Client {client_id}: {data}'
52+
53+
# Get the sender's nickname
54+
sender_name = manager.get_nickname(websocket)
55+
message = f'{sender_name}: {data}'
1956
await manager.broadcast(message)
2057
except WebSocketDisconnect:
2158
await manager.disconnect(websocket, client_id)

app/static/script.js

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,115 @@ let client_id = Date.now();
33
let ws;
44
let messageCount = 0;
55
let isConnected = false;
6+
let currentNickname = null;
67

78
// Initialize the application
89
document.addEventListener('DOMContentLoaded', function() {
9-
initializeWebSocket();
1010
setupEventListeners();
11-
updateConnectionStatus('connecting', 'Connecting...');
11+
showNicknameModal();
12+
updateConnectionStatus('connecting', 'Waiting for nickname...');
1213
});
1314

15+
// Nickname Modal Functions
16+
function showNicknameModal() {
17+
const modal = document.getElementById('nickname-modal');
18+
modal.classList.remove('hidden');
19+
20+
// Focus on input
21+
setTimeout(() => {
22+
document.getElementById('nickname-input').focus();
23+
}, 100);
24+
}
25+
26+
function hideNicknameModal() {
27+
const modal = document.getElementById('nickname-modal');
28+
modal.classList.add('hidden');
29+
}
30+
31+
function validateNickname(nickname) {
32+
const trimmed = nickname.trim();
33+
34+
if (trimmed.length === 0) {
35+
return { valid: false, error: 'Nickname cannot be empty' };
36+
}
37+
38+
if (trimmed.length < 2) {
39+
return { valid: false, error: 'Nickname must be at least 2 characters' };
40+
}
41+
42+
if (trimmed.length > 20) {
43+
return { valid: false, error: 'Nickname must be 20 characters or less' };
44+
}
45+
46+
// Allow alphanumeric, spaces, underscores, and hyphens
47+
const regex = /^[a-zA-Z0-9_ -]+$/;
48+
if (!regex.test(trimmed)) {
49+
return { valid: false, error: 'Only letters, numbers, spaces, _ and - allowed' };
50+
}
51+
52+
return { valid: true, nickname: trimmed };
53+
}
54+
55+
function submitNickname(event) {
56+
event.preventDefault();
57+
58+
const input = document.getElementById('nickname-input');
59+
const errorDiv = document.getElementById('nickname-error');
60+
const nickname = input.value;
61+
62+
// Validate nickname
63+
const validation = validateNickname(nickname);
64+
65+
if (!validation.valid) {
66+
errorDiv.textContent = validation.error;
67+
errorDiv.classList.add('show');
68+
input.focus();
69+
return;
70+
}
71+
72+
// Hide error if any
73+
errorDiv.classList.remove('show');
74+
75+
// Store nickname and connect
76+
currentNickname = validation.nickname;
77+
document.getElementById('nickname-display').textContent = currentNickname;
78+
79+
hideNicknameModal();
80+
initializeWebSocket();
81+
}
82+
83+
function editNickname() {
84+
// Disconnect current WebSocket
85+
if (ws && isConnected) {
86+
ws.close();
87+
}
88+
89+
// Clear current nickname
90+
currentNickname = null;
91+
document.getElementById('nickname-input').value = '';
92+
document.getElementById('nickname-char-count').textContent = '0';
93+
94+
// Show modal again
95+
showNicknameModal();
96+
updateConnectionStatus('connecting', 'Waiting for nickname...');
97+
}
98+
1499
// WebSocket connection management
15100
function initializeWebSocket() {
16-
document.querySelector("#ws-id").textContent = client_id;
17-
ws = new WebSocket(`ws://localhost:8000/ws/${client_id}`);
101+
if (!currentNickname) {
102+
showNicknameModal();
103+
return;
104+
}
105+
106+
updateConnectionStatus('connecting', 'Connecting...');
107+
108+
// Include nickname as query parameter
109+
ws = new WebSocket(`ws://localhost:8000/ws/${client_id}?nickname=${encodeURIComponent(currentNickname)}`);
18110

19111
ws.onopen = function(event) {
20112
isConnected = true;
21113
updateConnectionStatus('connected', 'Connected');
22-
addSystemMessage('Connected to Debezium Real-Time Chat! 🚀');
114+
addSystemMessage(`Connected to Debezium Real-Time Chat! 🚀`);
23115
};
24116

25117
ws.onmessage = function(event) {
@@ -29,8 +121,18 @@ function initializeWebSocket() {
29121
ws.onclose = function(event) {
30122
isConnected = false;
31123
updateConnectionStatus('disconnected', 'Disconnected');
32-
addSystemMessage('Connection lost. Attempting to reconnect...');
33-
setTimeout(initializeWebSocket, 3000);
124+
125+
// Check if close was due to nickname error
126+
if (event.code === 1008) {
127+
addSystemMessage(`❌ ${event.reason}`);
128+
showNicknameModal();
129+
const errorDiv = document.getElementById('nickname-error');
130+
errorDiv.textContent = event.reason;
131+
errorDiv.classList.add('show');
132+
} else {
133+
addSystemMessage('Connection lost. Attempting to reconnect...');
134+
setTimeout(initializeWebSocket, 3000);
135+
}
34136
};
35137

36138
ws.onerror = function(error) {
@@ -44,11 +146,22 @@ function setupEventListeners() {
44146
const messageInput = document.getElementById("messageText");
45147
const charCount = document.getElementById("char-count");
46148

47-
// Character counter
149+
// Character counter for message input
48150
messageInput.addEventListener('input', function() {
49151
charCount.textContent = this.value.length;
50152
});
51153

154+
// Character counter for nickname input
155+
const nicknameInput = document.getElementById("nickname-input");
156+
const nicknameCharCount = document.getElementById("nickname-char-count");
157+
158+
nicknameInput.addEventListener('input', function() {
159+
nicknameCharCount.textContent = this.value.length;
160+
// Clear error on input
161+
const errorDiv = document.getElementById('nickname-error');
162+
errorDiv.classList.remove('show');
163+
});
164+
52165
// Auto-resize input
53166
messageInput.addEventListener('input', function() {
54167
this.style.height = 'auto';
@@ -191,7 +304,7 @@ function addUserMessage(message) {
191304

192305
const contentDiv = document.createElement("div");
193306
contentDiv.className = "message-content";
194-
contentDiv.textContent = `👤 You: ${message}`;
307+
contentDiv.textContent = `👤 ${currentNickname || 'You'}: ${message}`;
195308

196309
const metaDiv = document.createElement("div");
197310
metaDiv.className = "message-meta";
@@ -251,3 +364,5 @@ window.insertSampleData = insertSampleData;
251364
window.updateSampleData = updateSampleData;
252365
window.deleteSampleData = deleteSampleData;
253366
window.sendMessage = sendMessage;
367+
window.submitNickname = submitNickname;
368+
window.editNickname = editNickname;

0 commit comments

Comments
 (0)