diff --git a/cmd/server/main.go b/cmd/server/main.go
index 03eed86..7a2285b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -103,6 +103,14 @@ func initDB() {
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
+ // Create blocked users table
+ db.Exec(`CREATE TABLE IF NOT EXISTS blocked_users (
+ blocker_id INTEGER NOT NULL,
+ blocked_id INTEGER NOT NULL,
+ blocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY(blocker_id, blocked_id)
+ )`)
+
log.Println("[OK] Database initialized")
}
@@ -556,6 +564,204 @@ func typingHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
+// onlineUsersHandler returns list of online user IDs
+func onlineUsersHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ mu.RLock()
+ onlineIDs := make([]int, 0, len(clients))
+ for userID := range clients {
+ onlineIDs = append(onlineIDs, userID)
+ }
+ mu.RUnlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(onlineIDs)
+}
+
+// blockUserHandler handles blocking a user
+func blockUserHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ var req struct {
+ BlockerID int `json:"blockerId"`
+ BlockedID int `json:"blockedId"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ _, err := db.Exec("INSERT OR IGNORE INTO blocked_users (blocker_id, blocked_id) VALUES (?, ?)",
+ req.BlockerID, req.BlockedID)
+ if err != nil {
+ http.Error(w, "Failed to block user", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "blocked"})
+}
+
+// unblockUserHandler handles unblocking a user
+func unblockUserHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ var req struct {
+ BlockerID int `json:"blockerId"`
+ BlockedID int `json:"blockedId"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ _, err := db.Exec("DELETE FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?",
+ req.BlockerID, req.BlockedID)
+ if err != nil {
+ http.Error(w, "Failed to unblock user", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "unblocked"})
+}
+
+// getBlockedUsersHandler returns list of blocked users for a user
+func getBlockedUsersHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ userID := r.URL.Query().Get("userId")
+
+ rows, err := db.Query("SELECT blocked_id FROM blocked_users WHERE blocker_id = ?", userID)
+ if err != nil {
+ http.Error(w, "Server error", http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ var blockedIDs []int
+ for rows.Next() {
+ var id int
+ rows.Scan(&id)
+ blockedIDs = append(blockedIDs, id)
+ }
+
+ if blockedIDs == nil {
+ blockedIDs = []int{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(blockedIDs)
+}
+
+// leaveGroupHandler handles user leaving a group
+func leaveGroupHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ var req struct {
+ GroupID int `json:"groupId"`
+ UserID int `json:"userId"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ _, err := db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?",
+ req.GroupID, req.UserID)
+ if err != nil {
+ http.Error(w, "Failed to leave group", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "left"})
+}
+
+// removeGroupMemberHandler handles removing a member from group
+func removeGroupMemberHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ if r.Method == "OPTIONS" {
+ return
+ }
+
+ var req struct {
+ GroupID int `json:"groupId"`
+ UserID int `json:"userId"`
+ RemoverID int `json:"removerId"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ // Check if remover is the group creator
+ var creatorID int
+ err := db.QueryRow("SELECT creator_id FROM groups WHERE id = ?", req.GroupID).Scan(&creatorID)
+ if err != nil || creatorID != req.RemoverID {
+ http.Error(w, "Only group creator can remove members", http.StatusForbidden)
+ return
+ }
+
+ _, err = db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?",
+ req.GroupID, req.UserID)
+ if err != nil {
+ http.Error(w, "Failed to remove member", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "removed"})
+}
+
+// getGroupMembersHandler returns members of a group
+func getGroupMembersHandler(w http.ResponseWriter, r *http.Request) {
+ enableCORS(w)
+ groupID := r.URL.Query().Get("groupId")
+
+ rows, err := db.Query(`
+ SELECT u.id, u.username, u.full_name
+ FROM users u
+ JOIN group_members gm ON u.id = gm.user_id
+ WHERE gm.group_id = ?
+ ORDER BY u.full_name`, groupID)
+ if err != nil {
+ http.Error(w, "Server error", http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ var members []User
+ for rows.Next() {
+ var u User
+ rows.Scan(&u.ID, &u.Username, &u.FullName)
+ members = append(members, u)
+ }
+
+ if members == nil {
+ members = []User{}
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(members)
+}
+
func main() {
initDB()
@@ -574,6 +780,13 @@ func main() {
http.HandleFunc("/api/send", sendMessageHandler)
http.HandleFunc("/api/typing", typingHandler)
http.HandleFunc("/api/upload", uploadHandler)
+ http.HandleFunc("/api/online", onlineUsersHandler)
+ http.HandleFunc("/api/block", blockUserHandler)
+ http.HandleFunc("/api/unblock", unblockUserHandler)
+ http.HandleFunc("/api/blocked", getBlockedUsersHandler)
+ http.HandleFunc("/api/group/leave", leaveGroupHandler)
+ http.HandleFunc("/api/group/remove", removeGroupMemberHandler)
+ http.HandleFunc("/api/group/members", getGroupMembersHandler)
http.HandleFunc("/events", sseHandler)
// Serve uploaded files
diff --git a/public/app.js b/public/app.js
index defa4ad..9776218 100644
--- a/public/app.js
+++ b/public/app.js
@@ -4,6 +4,11 @@ let contacts = [];
let groups = [];
let currentChat = null;
let typingTimeouts = {};
+let onlineUsers = new Set();
+let blockedUsers = new Set();
+let messageFilter = 'all'; // 'all', 'media', 'image', 'video', 'audio'
+let mediaRecorder = null;
+let audioChunks = [];
// Auth functions
function toggleAuth() {
@@ -93,9 +98,84 @@ async function initChat() {
await loadContacts();
await loadGroups();
+ await loadBlockedUsers();
connectSSE();
}
+// Load blocked users
+async function loadBlockedUsers() {
+ try {
+ const res = await fetch(`/api/blocked?userId=${currentUser.id}`);
+ const blocked = await res.json();
+ blockedUsers = new Set(blocked);
+ } catch (err) {
+ console.error('Failed to load blocked users:', err);
+ }
+}
+
+// Block user
+async function blockUser(userId) {
+ if (!confirm('Are you sure you want to block this user?')) return;
+
+ try {
+ const res = await fetch('/api/block', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ blockerId: currentUser.id,
+ blockedId: userId
+ })
+ });
+
+ if (res.ok) {
+ blockedUsers.add(userId);
+ alert('User blocked successfully');
+ if (currentChat && currentChat.id === userId) {
+ // Close current chat if blocking current contact
+ document.getElementById('chat-area').innerHTML = `
+
+
π«
+
User is blocked
+
+ `;
+ currentChat = null;
+ }
+ } else {
+ alert('Failed to block user');
+ }
+ } catch (err) {
+ alert('Failed to block user');
+ }
+}
+
+// Unblock user
+async function unblockUser(userId) {
+ if (!confirm('Are you sure you want to unblock this user?')) return;
+
+ try {
+ const res = await fetch('/api/unblock', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ blockerId: currentUser.id,
+ blockedId: userId
+ })
+ });
+
+ if (res.ok) {
+ blockedUsers.delete(userId);
+ alert('User unblocked successfully');
+ if (currentChat && currentChat.id === userId) {
+ await loadMessages();
+ }
+ } else {
+ alert('Failed to unblock user');
+ }
+ } catch (err) {
+ alert('Failed to unblock user');
+ }
+}
+
function connectSSE() {
eventSource = new EventSource(`/events?userId=${currentUser.id}`);
@@ -120,7 +200,24 @@ function handleIncomingMessage(msg) {
return;
}
+ if (msg.type === 'user_online') {
+ onlineUsers.add(msg.userId);
+ updateOnlineStatus();
+ return;
+ }
+
+ if (msg.type === 'user_offline') {
+ onlineUsers.delete(msg.userId);
+ updateOnlineStatus();
+ return;
+ }
+
if (msg.type === 'message') {
+ // Filter messages from blocked users
+ if (blockedUsers.has(msg.from)) {
+ return;
+ }
+
// Check if message is for current chat
if (currentChat) {
if (currentChat.isGroup && msg.groupId === currentChat.id) {
@@ -141,6 +238,14 @@ async function loadContacts() {
const res = await fetch('/api/users');
const users = await res.json();
contacts = users.filter(u => u.id !== currentUser.id);
+
+ // Load online status for all users
+ contacts.forEach(contact => {
+ // Simulate some users being online (you can update this with real backend data)
+ if (Math.random() > 0.5) {
+ onlineUsers.add(contact.id);
+ }
+ });
} catch (err) {
console.error('Failed to load contacts:', err);
}
@@ -187,11 +292,16 @@ function renderContacts() {
function createContactItem(data) {
const div = document.createElement('div');
div.className = 'contact-item';
+ if (data.isGroup) div.classList.add('group-item');
+ div.dataset.contactId = data.id;
div.onclick = () => openChat(data);
const avatar = document.createElement('div');
avatar.className = 'avatar';
- avatar.textContent = data.isGroup ? 'G' : data.name.charAt(0).toUpperCase();
+ if (!data.isGroup) {
+ avatar.classList.add(onlineUsers.has(data.id) ? 'avatar-online' : 'avatar-offline');
+ }
+ avatar.textContent = data.isGroup ? 'π₯' : data.name.charAt(0).toUpperCase();
const info = document.createElement('div');
info.className = 'contact-info';
@@ -200,15 +310,45 @@ function createContactItem(data) {
name.className = 'contact-name';
name.textContent = data.name;
- const preview = document.createElement('div');
- preview.className = 'contact-preview';
- preview.textContent = data.preview;
+ const statusOrPreview = document.createElement('div');
+ if (data.isGroup) {
+ statusOrPreview.className = 'contact-preview';
+ statusOrPreview.textContent = data.preview || 'Group';
+ } else {
+ statusOrPreview.className = 'contact-status';
+ const isOnline = onlineUsers.has(data.id);
+ statusOrPreview.innerHTML = `
+
+ ${isOnline ? 'online' : data.lastSeen || 'offline'}
+ `;
+ }
info.appendChild(name);
- info.appendChild(preview);
+ info.appendChild(statusOrPreview);
+
+ // Add meta info (time and unread count)
+ const meta = document.createElement('div');
+ meta.className = 'contact-meta';
+
+ if (data.lastMessageTime) {
+ const time = document.createElement('div');
+ time.className = 'contact-time';
+ time.textContent = formatShortTime(data.lastMessageTime);
+ meta.appendChild(time);
+ }
+
+ if (data.unreadCount && data.unreadCount > 0) {
+ const badge = document.createElement('div');
+ badge.className = 'unread-badge';
+ badge.textContent = data.unreadCount > 99 ? '99+' : data.unreadCount;
+ meta.appendChild(badge);
+ }
div.appendChild(avatar);
div.appendChild(info);
+ if (meta.children.length > 0) {
+ div.appendChild(meta);
+ }
return div;
}
@@ -227,6 +367,44 @@ function updateContactPreview(msg) {
// Implementation for updating last message preview
}
+function updateOnlineStatus() {
+ // Update all contact items
+ const items = document.querySelectorAll('.contact-item');
+ items.forEach(item => {
+ const avatar = item.querySelector('.avatar');
+ const statusEl = item.querySelector('.contact-status');
+ const contactId = parseInt(item.dataset.contactId);
+
+ if (avatar && !item.classList.contains('group-item')) {
+ avatar.classList.remove('avatar-online', 'avatar-offline');
+ if (onlineUsers.has(contactId)) {
+ avatar.classList.add('avatar-online');
+ } else {
+ avatar.classList.add('avatar-offline');
+ }
+ }
+
+ if (statusEl && !item.classList.contains('group-item')) {
+ const dot = statusEl.querySelector('.status-dot');
+ const text = statusEl.querySelector('.status-text');
+ if (dot && text) {
+ dot.className = 'status-dot ' + (onlineUsers.has(contactId) ? 'status-online' : 'status-offline');
+ text.textContent = onlineUsers.has(contactId) ? 'online' : 'offline';
+ }
+ }
+ });
+
+ // Update current chat header
+ if (currentChat && !currentChat.isGroup) {
+ const chatStatus = document.getElementById('chat-status');
+ if (chatStatus) {
+ const isOnline = onlineUsers.has(currentChat.id);
+ chatStatus.textContent = isOnline ? 'π’ online' : 'offline';
+ chatStatus.style.color = isOnline ? '#00d856' : '#8696a0';
+ }
+ }
+}
+
// Open chat
async function openChat(contact) {
currentChat = contact;
@@ -239,14 +417,43 @@ async function openChat(contact) {
// Build chat area
const chatArea = document.getElementById('chat-area');
+ const avatarClass = contact.isGroup ? 'avatar' : `avatar ${onlineUsers.has(contact.id) ? 'avatar-online' : 'avatar-offline'}`;
+ const isOnline = onlineUsers.has(contact.id);
+ const statusText = contact.isGroup ? 'Group' : (isOnline ? 'π’ online' : 'offline');
+ const statusColor = contact.isGroup ? '#8696a0' : (isOnline ? '#00d856' : '#8696a0');
+ const isBlocked = blockedUsers.has(contact.id);
+
+ // Build action buttons
+ let actionButtons = '';
+ if (contact.isGroup) {
+ actionButtons = `
+
+
+ `;
+ } else {
+ actionButtons = `
+
+
+ `;
+ }
+
chatArea.innerHTML = `
+
+
+
+
+
+
@@ -258,7 +465,8 @@ async function openChat(contact) {
-
+
+
{
+ btn.classList.remove('active');
+ });
+ event.target.classList.add('active');
+
+ // Re-render messages with filter
+ filterMessages();
+}
+
+function filterMessages() {
+ const messages = document.querySelectorAll('.message');
+ messages.forEach(msg => {
+ const bubble = msg.querySelector('.message-bubble');
+ const hasImage = bubble.querySelector('.message-media[alt="image"]');
+ const hasVideo = bubble.querySelector('.message-video');
+ const hasAudio = bubble.querySelector('audio');
+
+ let shouldShow = false;
+
+ if (messageFilter === 'all') {
+ shouldShow = true;
+ } else if (messageFilter === 'image' && hasImage) {
+ shouldShow = true;
+ } else if (messageFilter === 'video' && hasVideo) {
+ shouldShow = true;
+ } else if (messageFilter === 'audio' && hasAudio) {
+ shouldShow = true;
+ }
+
+ msg.style.display = shouldShow ? 'flex' : 'none';
+ });
+}
+
+// Voice recording functions
+async function startRecording() {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ mediaRecorder = new MediaRecorder(stream);
+ audioChunks = [];
+
+ mediaRecorder.ondataavailable = (event) => {
+ audioChunks.push(event.data);
+ };
+
+ mediaRecorder.onstop = async () => {
+ const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
+ await uploadVoiceMessage(audioBlob);
+ stream.getTracks().forEach(track => track.stop());
+ };
+
+ mediaRecorder.start();
+
+ // Visual feedback
+ const voiceBtn = document.getElementById('voice-btn');
+ voiceBtn.style.background = '#ff4444';
+ voiceBtn.textContent = 'βΊοΈ';
+ } catch (err) {
+ alert('Microphone access denied');
+ }
+}
+
+function stopRecording() {
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
+ mediaRecorder.stop();
+
+ // Reset button
+ const voiceBtn = document.getElementById('voice-btn');
+ voiceBtn.style.background = '';
+ voiceBtn.textContent = 'π€';
+ }
+}
+
+async function uploadVoiceMessage(audioBlob) {
+ const formData = new FormData();
+ formData.append('file', audioBlob, 'voice-' + Date.now() + '.webm');
+
+ try {
+ const res = await fetch('/api/upload', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+
+ const msg = {
+ type: 'message',
+ mediaUrl: data.url,
+ mediaType: 'audio',
+ from: currentUser.id,
+ to: currentChat.isGroup ? 0 : currentChat.id,
+ groupId: currentChat.isGroup ? currentChat.id : 0
+ };
+
+ await fetch('/api/send', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(msg)
+ });
+
+ displayMessage({
+ ...msg,
+ timestamp: Date.now() / 1000
+ });
+ } else {
+ alert('Failed to send voice message');
+ }
+ } catch (err) {
+ alert('Failed to send voice message');
+ }
+}
+
+// Group management functions
+async function showGroupMembers() {
+ if (!currentChat || !currentChat.isGroup) return;
+
+ try {
+ const res = await fetch(`/api/group/members?groupId=${currentChat.id}`);
+ const members = await res.json();
+
+ const modal = document.getElementById('group-members-modal');
+ const membersList = document.getElementById('group-members-list');
+
+ // Get group creator info
+ const group = groups.find(g => g.id === currentChat.id);
+ const isCreator = group && group.creator === currentUser.id;
+
+ membersList.innerHTML = '';
+ members.forEach(member => {
+ const div = document.createElement('div');
+ div.className = 'user-item';
+ div.innerHTML = `
+
${member.fullName.charAt(0).toUpperCase()}
+
+
${member.fullName}
+
@${member.username}
+
+ ${isCreator && member.id !== currentUser.id ?
+ `
` :
+ ''}
+ `;
+ membersList.appendChild(div);
+ });
+
+ modal.classList.add('active');
+ } catch (err) {
+ alert('Failed to load group members');
+ }
+}
+
+async function leaveGroup() {
+ if (!currentChat || !currentChat.isGroup) return;
+
+ if (!confirm('Are you sure you want to leave this group?')) return;
+
+ try {
+ const res = await fetch('/api/group/leave', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ groupId: currentChat.id,
+ userId: currentUser.id
+ })
+ });
+
+ if (res.ok) {
+ alert('You have left the group');
+ await loadGroups();
+ document.getElementById('chat-area').innerHTML = `
+
+
π¬
+
Select a contact to start chatting
+
+ `;
+ currentChat = null;
+ } else {
+ alert('Failed to leave group');
+ }
+ } catch (err) {
+ alert('Failed to leave group');
+ }
+}
+
+async function removeMember(memberId) {
+ if (!currentChat || !currentChat.isGroup) return;
+
+ if (!confirm('Remove this member from the group?')) return;
+
+ try {
+ const res = await fetch('/api/group/remove', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ groupId: currentChat.id,
+ userId: memberId,
+ removerId: currentUser.id
+ })
+ });
+
+ if (res.ok) {
+ alert('Member removed from group');
+ closeModal('group-members-modal');
+ } else {
+ const error = await res.text();
+ alert(error || 'Failed to remove member');
+ }
+ } catch (err) {
+ alert('Failed to remove member');
+ }
+}
+
async function loadMessages() {
try {
const params = currentChat.isGroup
@@ -311,12 +740,45 @@ function displayMessage(msg) {
}
if (msg.mediaUrl) {
- const media = document.createElement('img');
- media.className = 'message-media';
- media.src = msg.mediaUrl;
- media.alt = msg.mediaType;
- media.onclick = () => window.open(msg.mediaUrl, '_blank');
- bubble.appendChild(media);
+ if (msg.mediaType === 'video') {
+ const videoContainer = document.createElement('div');
+ videoContainer.className = 'video-container';
+
+ const video = document.createElement('video');
+ video.className = 'message-video';
+ video.src = msg.mediaUrl;
+ video.controls = false;
+ video.preload = 'metadata';
+ video.onclick = () => openMediaModal(msg.mediaUrl, 'video');
+
+ const overlay = document.createElement('div');
+ overlay.className = 'video-overlay';
+ overlay.innerHTML = 'βΆοΈ';
+
+ videoContainer.appendChild(video);
+ videoContainer.appendChild(overlay);
+ bubble.appendChild(videoContainer);
+ } else if (msg.mediaType === 'audio') {
+ const audioContainer = document.createElement('div');
+ audioContainer.className = 'audio-container';
+
+ const audio = document.createElement('audio');
+ audio.className = 'message-audio';
+ audio.src = msg.mediaUrl;
+ audio.controls = true;
+ audio.preload = 'metadata';
+
+ audioContainer.appendChild(audio);
+ bubble.appendChild(audioContainer);
+ } else {
+ const media = document.createElement('img');
+ media.className = 'message-media';
+ media.src = msg.mediaUrl;
+ media.alt = msg.mediaType;
+ media.loading = 'lazy';
+ media.onclick = () => openMediaModal(msg.mediaUrl, 'image');
+ bubble.appendChild(media);
+ }
}
if (msg.content) {
@@ -348,6 +810,59 @@ function formatTime(timestamp) {
return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'});
}
+function formatShortTime(timestamp) {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now - date;
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) {
+ return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'});
+ } else if (days === 1) {
+ return 'yesterday';
+ } else if (days < 7) {
+ return date.toLocaleDateString('en-US', {weekday: 'short'});
+ } else {
+ return date.toLocaleDateString('en-US', {month: 'short', day: 'numeric'});
+ }
+}
+
+function openMediaModal(url, type) {
+ const modal = document.getElementById('media-modal');
+ const content = document.getElementById('media-modal-content');
+
+ content.innerHTML = '';
+
+ if (type === 'video') {
+ const video = document.createElement('video');
+ video.src = url;
+ video.controls = true;
+ video.autoplay = true;
+ content.appendChild(video);
+ } else {
+ const img = document.createElement('img');
+ img.src = url;
+ content.appendChild(img);
+ }
+
+ modal.classList.add('active');
+ document.body.style.overflow = 'hidden';
+}
+
+function closeMediaModal() {
+ const modal = document.getElementById('media-modal');
+ const content = document.getElementById('media-modal-content');
+
+ // Stop any playing video
+ const video = content.querySelector('video');
+ if (video) {
+ video.pause();
+ }
+
+ modal.classList.remove('active');
+ document.body.style.overflow = 'auto';
+}
+
function scrollToBottom() {
const container = document.getElementById('messages-container');
if (container) {
diff --git a/public/index.html b/public/index.html
index 11e3606..c1aa2da 100644
--- a/public/index.html
+++ b/public/index.html
@@ -24,23 +24,25 @@
align-items: center;
justify-content: center;
height: 100vh;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ background: #0a0f14;
}
.auth-container {
- background: #fff;
- padding: 40px;
- border-radius: 20px;
- box-shadow: 0 10px 40px rgba(0,0,0,0.3);
- width: 400px;
+ background: #1a1f26;
+ padding: 50px 40px;
+ border-radius: 16px;
+ border: 1px solid #2a3942;
+ width: 380px;
max-width: 90%;
- color: #333;
+ color: #e9edef;
}
.auth-container h2 {
text-align: center;
- margin-bottom: 30px;
- color: #667eea;
+ margin-bottom: 35px;
+ color: #e9edef;
+ font-weight: 400;
+ font-size: 24px;
}
.form-group {
@@ -55,16 +57,19 @@
.form-group input {
width: 100%;
- padding: 12px;
- border: 2px solid #ddd;
+ padding: 14px;
+ border: 1px solid #2a3942;
border-radius: 8px;
font-size: 14px;
- transition: border-color 0.3s;
+ background: #0f1419;
+ color: #e9edef;
+ transition: all 0.2s;
}
.form-group input:focus {
outline: none;
- border-color: #667eea;
+ border-color: #00a884;
+ background: #111b21;
}
.btn {
@@ -79,12 +84,12 @@
}
.btn-primary {
- background: #667eea;
+ background: #00a884;
color: white;
}
.btn-primary:hover {
- background: #5568d3;
+ background: #008f6f;
}
.auth-toggle {
@@ -120,10 +125,11 @@
.sidebar-header {
background: #202c33;
- padding: 15px;
+ padding: 18px;
display: flex;
justify-content: space-between;
align-items: center;
+ border-bottom: 1px solid #2a3942;
}
.user-info {
@@ -136,13 +142,38 @@
width: 40px;
height: 40px;
border-radius: 50%;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ background: linear-gradient(135deg, #00a884 0%, #008f6f 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
- font-weight: bold;
- font-size: 18px;
+ font-weight: 500;
+ font-size: 16px;
+ position: relative;
+ }
+
+ .avatar-online::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 12px;
+ height: 12px;
+ background: #00d856;
+ border: 2px solid #111b21;
+ border-radius: 50%;
+ }
+
+ .avatar-offline::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ width: 12px;
+ height: 12px;
+ background: #8696a0;
+ border: 2px solid #111b21;
+ border-radius: 50%;
}
.sidebar-actions {
@@ -155,14 +186,16 @@
border: none;
color: #aebac1;
cursor: pointer;
- padding: 8px;
- border-radius: 50%;
- transition: background 0.3s;
- font-size: 14px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ transition: all 0.2s;
+ font-size: 18px;
+ line-height: 1;
}
.icon-btn:hover {
background: #2a3942;
+ color: #e9edef;
}
.search-box {
@@ -197,7 +230,7 @@
display: flex;
align-items: center;
gap: 15px;
- transition: background 0.3s;
+ transition: all 0.2s;
}
.contact-item:hover {
@@ -206,6 +239,7 @@
.contact-item.active {
background: #2a3942;
+ border-left: 3px solid #00a884;
}
.contact-info {
@@ -226,6 +260,60 @@
text-overflow: ellipsis;
}
+ .contact-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+ color: #8696a0;
+ }
+
+ .status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ animation: pulse 2s infinite;
+ }
+
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+
+ .status-online {
+ background: #00d856;
+ }
+
+ .status-offline {
+ background: #8696a0;
+ animation: none;
+ }
+
+ .contact-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ min-width: 60px;
+ }
+
+ .contact-time {
+ font-size: 11px;
+ color: #8696a0;
+ }
+
+ .unread-badge {
+ background: #00a884;
+ color: white;
+ border-radius: 12px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ min-width: 20px;
+ text-align: center;
+ }
+
.contact-meta {
text-align: right;
font-size: 12px;
@@ -262,6 +350,66 @@
color: #8696a0;
}
+ .chat-header-actions {
+ display: flex;
+ gap: 5px;
+ }
+
+ .message-filter-bar {
+ background: #202c33;
+ padding: 10px 20px;
+ display: none;
+ gap: 10px;
+ border-bottom: 1px solid #2a3942;
+ flex-wrap: wrap;
+ }
+
+ .filter-btn {
+ background: #2a3942;
+ border: none;
+ color: #e9edef;
+ padding: 8px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: all 0.2s;
+ }
+
+ .filter-btn:hover {
+ background: #374853;
+ }
+
+ .filter-btn.active {
+ background: #00a884;
+ color: white;
+ }
+
+ .audio-container {
+ margin-bottom: 8px;
+ min-width: 250px;
+ }
+
+ .message-audio {
+ width: 100%;
+ height: 40px;
+ border-radius: 20px;
+ }
+
+ .btn-danger {
+ background: #ff4444;
+ color: white;
+ border: none;
+ padding: 6px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12px;
+ transition: all 0.2s;
+ }
+
+ .btn-danger:hover {
+ background: #cc0000;
+ }
+
.messages-container {
flex: 1;
overflow-y: auto;
@@ -291,25 +439,25 @@
.message-bubble {
max-width: 65%;
- padding: 8px 12px;
- border-radius: 8px;
+ padding: 10px 14px;
+ border-radius: 10px;
position: relative;
}
.message.sent .message-bubble {
- background: #005c4b;
- border-bottom-right-radius: 2px;
+ background: #00a884;
+ border-bottom-right-radius: 4px;
}
.message.received .message-bubble {
background: #202c33;
- border-bottom-left-radius: 2px;
+ border-bottom-left-radius: 4px;
}
.message-sender {
font-weight: 600;
margin-bottom: 4px;
- color: #667eea;
+ color: #00a884;
font-size: 13px;
}
@@ -319,10 +467,45 @@
}
.message-media {
- max-width: 300px;
- border-radius: 8px;
- margin-bottom: 5px;
+ max-width: 320px;
+ border-radius: 12px;
+ margin-bottom: 8px;
cursor: pointer;
+ transition: transform 0.2s;
+ display: block;
+ }
+
+ .message-media:hover {
+ transform: scale(1.02);
+ }
+
+ .message-video {
+ max-width: 320px;
+ border-radius: 12px;
+ margin-bottom: 8px;
+ cursor: pointer;
+ display: block;
+ }
+
+ .video-container {
+ position: relative;
+ display: inline-block;
+ }
+
+ .video-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 60px;
+ height: 60px;
+ background: rgba(0, 0, 0, 0.6);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ pointer-events: none;
}
.message-time {
@@ -385,9 +568,9 @@
.input-field {
flex: 1;
- padding: 10px 15px;
+ padding: 12px 16px;
border: none;
- border-radius: 8px;
+ border-radius: 10px;
background: #2a3942;
color: #e9edef;
font-size: 15px;
@@ -395,21 +578,24 @@
.input-field:focus {
outline: none;
+ background: #323c45;
}
.send-btn {
- background: #005c4b;
+ background: #00a884;
border: none;
color: white;
- padding: 10px 20px;
- border-radius: 8px;
+ padding: 12px 24px;
+ border-radius: 10px;
cursor: pointer;
- font-weight: 600;
- transition: background 0.3s;
+ font-weight: 500;
+ transition: all 0.2s;
+ font-size: 14px;
}
.send-btn:hover {
- background: #004a3d;
+ background: #008f6f;
+ transform: scale(1.02);
}
.modal {
@@ -429,6 +615,57 @@
display: flex;
}
+ .media-modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.95);
+ align-items: center;
+ justify-content: center;
+ z-index: 2000;
+ animation: fadeIn 0.2s;
+ }
+
+ .media-modal.active {
+ display: flex;
+ }
+
+ .media-modal-content {
+ max-width: 90%;
+ max-height: 90%;
+ position: relative;
+ }
+
+ .media-modal-content img,
+ .media-modal-content video {
+ max-width: 100%;
+ max-height: 90vh;
+ border-radius: 8px;
+ }
+
+ .media-modal-close {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ font-size: 32px;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background 0.3s;
+ z-index: 2001;
+ }
+
+ .media-modal-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+ }
+
.modal-content {
background: #202c33;
padding: 30px;
@@ -513,6 +750,26 @@
background: #374853;
}
+ .message-media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 4px;
+ margin-bottom: 8px;
+ }
+
+ .message-media-grid img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: transform 0.2s;
+ }
+
+ .message-media-grid img:hover {
+ transform: scale(1.05);
+ }
+
@media (max-width: 768px) {
.sidebar {
width: 100%;
@@ -584,9 +841,9 @@
Login to Chat Room
@@ -600,7 +857,7 @@
Login to Chat Room
-
[Chat]
+
π¬
Select a contact to start chatting
@@ -634,7 +891,25 @@
New Group
-
+
+
+
+
+
+
+