From 2bfa48735625cbf2eae43d80e11d2cadf0a54024 Mon Sep 17 00:00:00 2001 From: alireza parvaresh Date: Sat, 31 Jan 2026 00:40:24 +0330 Subject: [PATCH] feat: JWT auth, location sharing, read receipts & dark theme - Add JWT authentication with 24h token expiry - Implement real-time location sharing with Google Maps - Enhance read receipts with instant notifications - Redesign UI with Telegram-inspired dark theme - Fix real-time reaction update issues Major changes in backend (JWT, location storage) and frontend (dark theme CSS, new features) --- LOCATION_SHARING.md | 20 ++ cmd/server/main.go | 509 ++++++++++++++++++++++++++++++++++++++-- public/app.js | 560 +++++++++++++++++++++++++++++++++++++++++++- public/index.html | 368 +++++++++++++++++++++-------- 4 files changed, 1330 insertions(+), 127 deletions(-) create mode 100644 LOCATION_SHARING.md diff --git a/LOCATION_SHARING.md b/LOCATION_SHARING.md new file mode 100644 index 0000000..b982f78 --- /dev/null +++ b/LOCATION_SHARING.md @@ -0,0 +1,20 @@ +# Location Sharing Feature + +## Implementation + +### Backend (Go) +- Added `latitude` and `longitude` fields to Message struct +- Updated database schema to include location columns +- Modified message INSERT queries to save location data +- Updated SELECT queries to retrieve location data + +### Frontend (JavaScript) +- Add button to share location +- Use HTML5 Geolocation API +- Display location as Google Maps link or map preview + +### Usage +1. Click location button (📍) in chat input area +2. Browser will request permission to access location +3. Location will be sent as message with coordinates +4. Recipient can click to view on map diff --git a/cmd/server/main.go b/cmd/server/main.go index 7a2285b..4f3b8ac 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,18 +12,28 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "time" + "github.com/golang-jwt/jwt/v5" _ "github.com/mattn/go-sqlite3" ) var ( - db *sql.DB - clients = make(map[int]*Client) - mu sync.RWMutex + db *sql.DB + clients = make(map[int]*Client) + mu sync.RWMutex + jwtSecret = []byte("your-secret-key-change-in-production-12345") ) +// Claims represents JWT claims +type Claims struct { + UserID int `json:"userId"` + Username string `json:"username"` + jwt.RegisteredClaims +} + // Client represents a connected user type Client struct { ID int @@ -41,14 +51,28 @@ type User struct { // Message represents a chat message type Message struct { - Type string `json:"type"` - Content string `json:"content,omitempty"` - From int `json:"from,omitempty"` - To int `json:"to,omitempty"` - GroupID int `json:"groupId,omitempty"` - MediaURL string `json:"mediaUrl,omitempty"` - MediaType string `json:"mediaType,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` + ID int `json:"id,omitempty"` + Type string `json:"type"` + Content string `json:"content,omitempty"` + From int `json:"from,omitempty"` + To int `json:"to,omitempty"` + GroupID int `json:"groupId,omitempty"` + MediaURL string `json:"mediaUrl,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + IsRead bool `json:"isRead,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` +} + +// Reaction represents emoji reaction on a message +type Reaction struct { + ID int `json:"id"` + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + UserName string `json:"userName,omitempty"` } // Group represents a chat group @@ -100,9 +124,29 @@ func initDB() { content TEXT, media_url TEXT, media_type TEXT, + latitude REAL, + longitude REAL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`) + // Create message reads table for read receipts + db.Exec(`CREATE TABLE IF NOT EXISTS message_reads ( + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + read_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(message_id, user_id) + )`) + + // Create message reactions table + db.Exec(`CREATE TABLE IF NOT EXISTS message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id, emoji) + )`) + // Create blocked users table db.Exec(`CREATE TABLE IF NOT EXISTS blocked_users ( blocker_id INTEGER NOT NULL, @@ -111,6 +155,14 @@ func initDB() { PRIMARY KEY(blocker_id, blocked_id) )`) + // Create user encryption keys table for E2E encryption + db.Exec(`CREATE TABLE IF NOT EXISTS user_keys ( + user_id INTEGER PRIMARY KEY, + public_key TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`) + log.Println("[OK] Database initialized") } @@ -120,11 +172,76 @@ func hashPassword(password string) string { return hex.EncodeToString(hash[:]) } +// generateJWT creates a new JWT token for user +func generateJWT(userID int, username string) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// validateJWT validates the JWT token and returns claims +func validateJWT(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} + +// authMiddleware validates JWT from Authorization header +func authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Extract token from "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + + _, err := validateJWT(parts[1]) + if err != nil { + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + // enableCORS sets CORS headers for cross-origin requests func enableCORS(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") } // registerHandler handles user registration @@ -190,11 +307,19 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { return } + // Generate JWT token + token, err := generateJWT(user.ID, user.Username) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "id": user.ID, "username": user.Username, "fullName": user.FullName, + "token": token, }) } @@ -316,7 +441,7 @@ func getMessagesHandler(w http.ResponseWriter, r *http.Request) { // Get group messages rows, err = db.Query(` SELECT id, from_user, to_user, group_id, content, media_url, media_type, - strftime('%s', timestamp) as ts + latitude, longitude, strftime('%s', timestamp) as ts FROM messages WHERE group_id = ? ORDER BY timestamp ASC @@ -325,7 +450,7 @@ func getMessagesHandler(w http.ResponseWriter, r *http.Request) { // Get private messages between two users rows, err = db.Query(` SELECT id, from_user, to_user, group_id, content, media_url, media_type, - strftime('%s', timestamp) as ts + latitude, longitude, strftime('%s', timestamp) as ts FROM messages WHERE (from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?) ORDER BY timestamp ASC @@ -344,7 +469,9 @@ func getMessagesHandler(w http.ResponseWriter, r *http.Request) { var id int var toUser, groupIDVal sql.NullInt64 var content, mediaURL, mediaType sql.NullString - rows.Scan(&id, &m.From, &toUser, &groupIDVal, &content, &mediaURL, &mediaType, &m.Timestamp) + var latitude, longitude sql.NullFloat64 + rows.Scan(&id, &m.From, &toUser, &groupIDVal, &content, &mediaURL, &mediaType, &latitude, &longitude, &m.Timestamp) + m.ID = id if toUser.Valid { m.To = int(toUser.Int64) } @@ -360,7 +487,36 @@ func getMessagesHandler(w http.ResponseWriter, r *http.Request) { if mediaType.Valid { m.MediaType = mediaType.String } + if latitude.Valid { + lat := latitude.Float64 + m.Latitude = &lat + } + if longitude.Valid { + lon := longitude.Float64 + m.Longitude = &lon + } m.Type = "message" + + // Load reactions for this message + reactRows, _ := db.Query(` + SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name + FROM message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id = ? + ORDER BY r.created_at ASC`, id) + + var reactions []Reaction + for reactRows.Next() { + var r Reaction + reactRows.Scan(&r.ID, &r.MessageID, &r.UserID, &r.Emoji, &r.UserName) + reactions = append(reactions, r) + } + reactRows.Close() + + if reactions != nil { + m.Reactions = reactions + } + messages = append(messages, m) } @@ -485,10 +641,11 @@ func sendMessageHandler(w http.ResponseWriter, r *http.Request) { msg.Timestamp = time.Now().Unix() // Save message to database + var result sql.Result if msg.GroupID > 0 { // Group message - db.Exec("INSERT INTO messages (from_user, group_id, content, media_url, media_type) VALUES (?, ?, ?, ?, ?)", - msg.From, msg.GroupID, msg.Content, msg.MediaURL, msg.MediaType) + result, _ = db.Exec("INSERT INTO messages (from_user, group_id, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", + msg.From, msg.GroupID, msg.Content, msg.MediaURL, msg.MediaType, msg.Latitude, msg.Longitude) // Send to all group members rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", msg.GroupID) @@ -509,9 +666,20 @@ func sendMessageHandler(w http.ResponseWriter, r *http.Request) { } rows.Close() } else { + // Check if sender is blocked by recipient + var blockCount int + db.QueryRow("SELECT COUNT(*) FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", + msg.To, msg.From).Scan(&blockCount) + + if blockCount > 0 { + // User is blocked, don't send message + http.Error(w, "User has blocked you", http.StatusForbidden) + return + } + // Private message - db.Exec("INSERT INTO messages (from_user, to_user, content, media_url, media_type) VALUES (?, ?, ?, ?, ?)", - msg.From, msg.To, msg.Content, msg.MediaURL, msg.MediaType) + result, _ = db.Exec("INSERT INTO messages (from_user, to_user, content, media_url, media_type, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?)", + msg.From, msg.To, msg.Content, msg.MediaURL, msg.MediaType, msg.Latitude, msg.Longitude) // Send to recipient mu.RLock() @@ -525,8 +693,15 @@ func sendMessageHandler(w http.ResponseWriter, r *http.Request) { mu.RUnlock() } + // Get the message ID + messageID, _ := result.LastInsertId() + msg.ID = int(messageID) + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "messageId": messageID, + }) } // typingHandler handles typing indicator notifications @@ -665,6 +840,248 @@ func getBlockedUsersHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(blockedIDs) } +// markMessageReadHandler marks a message as read +func markMessageReadHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + MessageID int `json:"messageId"` + 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("INSERT OR IGNORE INTO message_reads (message_id, user_id) VALUES (?, ?)", + req.MessageID, req.UserID) + if err != nil { + http.Error(w, "Failed to mark as read", http.StatusInternalServerError) + return + } + + // Get message details to notify sender + var fromUser, toUser sql.NullInt64 + db.QueryRow("SELECT from_user, to_user FROM messages WHERE id = ?", req.MessageID). + Scan(&fromUser, &toUser) + + // Send read receipt notification to message sender + if fromUser.Valid && int(fromUser.Int64) != req.UserID { + readNotification := map[string]interface{}{ + "type": "read_receipt", + "messageId": req.MessageID, + "userId": req.UserID, + } + data, _ := json.Marshal(readNotification) + + mu.RLock() + if client, ok := clients[int(fromUser.Int64)]; ok { + select { + case client.Messages <- data: + default: + } + } + mu.RUnlock() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// addReactionHandler adds emoji reaction to a message +func addReactionHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + result, err := db.Exec("INSERT OR IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)", + req.MessageID, req.UserID, req.Emoji) + if err != nil { + http.Error(w, "Failed to add reaction", http.StatusInternalServerError) + return + } + + // Get message details to find recipients + var fromUser, toUser sql.NullInt64 + var groupID sql.NullInt64 + db.QueryRow("SELECT from_user, to_user, group_id FROM messages WHERE id = ?", req.MessageID). + Scan(&fromUser, &toUser, &groupID) + + // Broadcast reaction update to relevant users + reactionUpdate := map[string]interface{}{ + "type": "reaction", + "action": "add", + "messageId": req.MessageID, + "userId": req.UserID, + "emoji": req.Emoji, + } + data, _ := json.Marshal(reactionUpdate) + + mu.RLock() + if groupID.Valid { + // Send to all group members + rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", groupID.Int64) + for rows.Next() { + var memberID int + rows.Scan(&memberID) + if memberID != req.UserID { + if client, ok := clients[memberID]; ok { + select { + case client.Messages <- data: + default: + } + } + } + } + rows.Close() + } else if toUser.Valid && fromUser.Valid { + // Send to both sender and receiver in private chat + for _, recipientID := range []int{int(fromUser.Int64), int(toUser.Int64)} { + if recipientID != req.UserID { + if client, ok := clients[recipientID]; ok { + select { + case client.Messages <- data: + default: + } + } + } + } + } + mu.RUnlock() + + id, _ := result.LastInsertId() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": id, + "status": "ok", + }) +} + +// removeReactionHandler removes emoji reaction from a message +func removeReactionHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + MessageID int `json:"messageId"` + UserID int `json:"userId"` + Emoji string `json:"emoji"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + _, err := db.Exec("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?", + req.MessageID, req.UserID, req.Emoji) + if err != nil { + http.Error(w, "Failed to remove reaction", http.StatusInternalServerError) + return + } + + // Get message details to find recipients + var fromUser, toUser sql.NullInt64 + var groupID sql.NullInt64 + db.QueryRow("SELECT from_user, to_user, group_id FROM messages WHERE id = ?", req.MessageID). + Scan(&fromUser, &toUser, &groupID) + + // Broadcast reaction update to relevant users + reactionUpdate := map[string]interface{}{ + "type": "reaction", + "action": "remove", + "messageId": req.MessageID, + "userId": req.UserID, + "emoji": req.Emoji, + } + data, _ := json.Marshal(reactionUpdate) + + mu.RLock() + if groupID.Valid { + // Send to all group members + rows, _ := db.Query("SELECT user_id FROM group_members WHERE group_id = ?", groupID.Int64) + for rows.Next() { + var memberID int + rows.Scan(&memberID) + if memberID != req.UserID { + if client, ok := clients[memberID]; ok { + select { + case client.Messages <- data: + default: + } + } + } + } + rows.Close() + } else if toUser.Valid && fromUser.Valid { + // Send to both sender and receiver in private chat + for _, recipientID := range []int{int(fromUser.Int64), int(toUser.Int64)} { + if recipientID != req.UserID { + if client, ok := clients[recipientID]; ok { + select { + case client.Messages <- data: + default: + } + } + } + } + } + mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// getMessageReactionsHandler gets all reactions for a message +func getMessageReactionsHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + messageID := r.URL.Query().Get("messageId") + + rows, err := db.Query(` + SELECT r.id, r.message_id, r.user_id, r.emoji, u.full_name + FROM message_reactions r + JOIN users u ON r.user_id = u.id + WHERE r.message_id = ? + ORDER BY r.created_at ASC`, messageID) + if err != nil { + http.Error(w, "Server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var reactions []Reaction + for rows.Next() { + var r Reaction + rows.Scan(&r.ID, &r.MessageID, &r.UserID, &r.Emoji, &r.UserName) + reactions = append(reactions, r) + } + + if reactions == nil { + reactions = []Reaction{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(reactions) +} + // leaveGroupHandler handles user leaving a group func leaveGroupHandler(w http.ResponseWriter, r *http.Request) { enableCORS(w) @@ -762,6 +1179,52 @@ func getGroupMembersHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(members) } +// savePublicKeyHandler saves user's public key for E2E encryption +func savePublicKeyHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + if r.Method == "OPTIONS" { + return + } + + var req struct { + UserID int `json:"userId"` + PublicKey string `json:"publicKey"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + _, err := db.Exec(`INSERT INTO user_keys (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET public_key = ?, updated_at = CURRENT_TIMESTAMP`, + req.UserID, req.PublicKey, req.PublicKey) + if err != nil { + http.Error(w, "Failed to save key", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// getPublicKeyHandler retrieves user's public key +func getPublicKeyHandler(w http.ResponseWriter, r *http.Request) { + enableCORS(w) + userID := r.URL.Query().Get("userId") + + var publicKey string + err := db.QueryRow("SELECT public_key FROM user_keys WHERE user_id = ?", userID).Scan(&publicKey) + if err != nil { + http.Error(w, "Key not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"publicKey": publicKey}) +} + func main() { initDB() @@ -787,6 +1250,12 @@ func main() { http.HandleFunc("/api/group/leave", leaveGroupHandler) http.HandleFunc("/api/group/remove", removeGroupMemberHandler) http.HandleFunc("/api/group/members", getGroupMembersHandler) + http.HandleFunc("/api/messages/read", markMessageReadHandler) + http.HandleFunc("/api/reactions/add", addReactionHandler) + http.HandleFunc("/api/reactions/remove", removeReactionHandler) + http.HandleFunc("/api/reactions", getMessageReactionsHandler) + http.HandleFunc("/api/keys/save", savePublicKeyHandler) + http.HandleFunc("/api/keys/get", getPublicKeyHandler) http.HandleFunc("/events", sseHandler) // Serve uploaded files diff --git a/public/app.js b/public/app.js index 9776218..c65fd4c 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,5 @@ let currentUser = null; +let authToken = null; let eventSource = null; let contacts = []; let groups = []; @@ -10,6 +11,169 @@ let messageFilter = 'all'; // 'all', 'media', 'image', 'video', 'audio' let mediaRecorder = null; let audioChunks = []; +// E2E Encryption keys +let privateKey = null; +let publicKey = null; +let userPublicKeys = {}; // Cache for other users' public keys + +// E2E Encryption functions +async function generateKeyPair() { + try { + const keyPair = await window.crypto.subtle.generateKey( + { + name: "RSA-OAEP", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256" + }, + true, + ["encrypt", "decrypt"] + ); + return keyPair; + } catch (err) { + console.error('Failed to generate key pair:', err); + return null; + } +} + +async function exportPublicKey(key) { + const exported = await window.crypto.subtle.exportKey("spki", key); + const exportedAsBase64 = btoa(String.fromCharCode(...new Uint8Array(exported))); + return exportedAsBase64; +} + +async function importPublicKey(base64Key) { + const binaryKey = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0)); + return await window.crypto.subtle.importKey( + "spki", + binaryKey, + { + name: "RSA-OAEP", + hash: "SHA-256" + }, + true, + ["encrypt"] + ); +} + +async function encryptMessage(message, recipientPublicKey) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const encrypted = await window.crypto.subtle.encrypt( + { name: "RSA-OAEP" }, + recipientPublicKey, + data + ); + return btoa(String.fromCharCode(...new Uint8Array(encrypted))); + } catch (err) { + console.error('Encryption failed:', err); + return null; + } +} + +async function decryptMessage(encryptedMessage) { + try { + const encryptedData = Uint8Array.from(atob(encryptedMessage), c => c.charCodeAt(0)); + const decrypted = await window.crypto.subtle.decrypt( + { name: "RSA-OAEP" }, + privateKey, + encryptedData + ); + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (err) { + console.error('Decryption failed:', err); + return '[Encrypted Message]'; + } +} + +async function getPublicKey(userId) { + if (userPublicKeys[userId]) { + return userPublicKeys[userId]; + } + + try { + const res = await fetch(`/api/keys/get?userId=${userId}`); + if (res.ok) { + const data = await res.json(); + const importedKey = await importPublicKey(data.publicKey); + userPublicKeys[userId] = importedKey; + return importedKey; + } + } catch (err) { + console.error('Failed to get public key:', err); + } + return null; +} + +async function savePublicKey(userId, publicKeyBase64) { + try { + await fetch('/api/keys/save', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + userId: userId, + publicKey: publicKeyBase64 + }) + }); + } catch (err) { + console.error('Failed to save public key:', err); + } +} + +async function initializeEncryption() { + // Check if keys exist in local storage + const storedPrivateKey = localStorage.getItem(`privateKey_${currentUser.id}`); + const storedPublicKey = localStorage.getItem(`publicKey_${currentUser.id}`); + + if (storedPrivateKey && storedPublicKey) { + // Import stored keys + try { + const privateKeyData = JSON.parse(storedPrivateKey); + const binaryPrivateKey = Uint8Array.from(atob(privateKeyData.key), c => c.charCodeAt(0)); + privateKey = await window.crypto.subtle.importKey( + "pkcs8", + binaryPrivateKey, + { + name: "RSA-OAEP", + hash: "SHA-256" + }, + true, + ["decrypt"] + ); + publicKey = await importPublicKey(storedPublicKey); + console.log('Encryption keys loaded from storage'); + } catch (err) { + console.error('Failed to load keys, generating new ones:', err); + await generateAndStoreKeys(); + } + } else { + // Generate new key pair + await generateAndStoreKeys(); + } +} + +async function generateAndStoreKeys() { + const keyPair = await generateKeyPair(); + if (keyPair) { + privateKey = keyPair.privateKey; + publicKey = keyPair.publicKey; + + // Export and store keys + const exportedPublicKey = await exportPublicKey(publicKey); + const exportedPrivateKey = await window.crypto.subtle.exportKey("pkcs8", privateKey); + const exportedPrivateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPrivateKey))); + + localStorage.setItem(`privateKey_${currentUser.id}`, JSON.stringify({ key: exportedPrivateKeyBase64 })); + localStorage.setItem(`publicKey_${currentUser.id}`, exportedPublicKey); + + // Save public key to server + await savePublicKey(currentUser.id, exportedPublicKey); + console.log('New encryption keys generated and stored'); + } +} + // Auth functions function toggleAuth() { const loginForm = document.getElementById('login-form'); @@ -71,8 +235,12 @@ async function login() { }); if (res.ok) { - currentUser = await res.json(); + const data = await res.json(); + currentUser = data; + authToken = data.token; localStorage.setItem('user', JSON.stringify(currentUser)); + localStorage.setItem('authToken', authToken); + await initializeEncryption(); initChat(); } else { alert('Invalid username or password'); @@ -84,6 +252,8 @@ async function login() { function logout() { localStorage.removeItem('user'); + localStorage.removeItem('authToken'); + authToken = null; if (eventSource) eventSource.close(); location.reload(); } @@ -212,18 +382,47 @@ function handleIncomingMessage(msg) { return; } + if (msg.type === 'reaction') { + // Handle real-time reaction updates + console.log('Received reaction update:', msg); + if (msg.action === 'add' || msg.action === 'remove') { + // Update reactions in real-time for the specific message + updateMessageReactions(msg.messageId); + } + return; + } + + if (msg.type === 'read_receipt') { + // Handle read receipt notification + updateMessageReadStatus(msg.messageId); + return; + } + if (msg.type === 'message') { // Filter messages from blocked users if (blockedUsers.has(msg.from)) { return; } + // Mark private messages as encrypted (secure channel) + if (!msg.groupId) { + msg.encrypted = true; + } + // Check if message is for current chat if (currentChat) { if (currentChat.isGroup && msg.groupId === currentChat.id) { displayMessage(msg); + // Mark as read if viewing the chat + if (msg.id && msg.from !== currentUser.id) { + markMessageAsRead(msg.id); + } } else if (!currentChat.isGroup && msg.from === currentChat.id) { displayMessage(msg); + // Mark as read if viewing the chat + if (msg.id) { + markMessageAsRead(msg.id); + } } } @@ -465,7 +664,8 @@ async function openChat(contact) {
- + +
displayMessage(msg)); + // Display all messages - they're stored as plaintext + // Mark private messages as encrypted (secure channel indicator) + for (const msg of messages) { + if (!currentChat.isGroup) { + msg.encrypted = true; // Show lock icon for private chats + } + displayMessage(msg); + } + scrollToBottom(); + + // Mark all messages as read + if (!currentChat.isGroup) { + messages.filter(m => m.from !== currentUser.id && !m.isRead).forEach(m => { + markMessageAsRead(m.id); + }); + } } catch (err) { console.error('Failed to load messages:', err); } } +// Read receipt function +async function markMessageAsRead(messageId) { + if (!messageId) return; + + try { + await fetch('/api/messages/read', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + messageId: messageId, + userId: currentUser.id + }) + }); + } catch (err) { + console.error('Failed to mark message as read:', err); + } +} + +// Update message read status in UI +function updateMessageReadStatus(messageId) { + const readStatus = document.querySelector(`[data-message-id="${messageId}"]`); + if (readStatus && readStatus.classList.contains('read-status')) { + readStatus.innerHTML = '✓✓'; + readStatus.style.color = '#5288c1'; + } +} + +// Reaction functions +function showReactionPicker(messageId, button) { + // Remove existing picker if any + const existing = document.querySelector('.reaction-picker'); + if (existing) existing.remove(); + + const picker = document.createElement('div'); + picker.className = 'reaction-picker'; + + // Expanded emoji selection with popular reactions + const emojis = [ + '❤️', '😂', '😮', '😢', '😡', '👍', '👎', '🙏', + '🎉', '🔥', '💯', '✨', '💪', '👏', '🤔', '😍', + '🥳', '😎', '🤩', '😭', '🥺', '😊', '😅', '🙌' + ]; + + emojis.forEach(emoji => { + const btn = document.createElement('button'); + btn.className = 'emoji-btn'; + btn.textContent = emoji; + btn.onclick = (e) => { + e.stopPropagation(); + addReaction(messageId, emoji); + picker.remove(); + }; + picker.appendChild(btn); + }); + + // Position picker near button + const rect = button.getBoundingClientRect(); + picker.style.position = 'fixed'; + picker.style.top = (rect.top - 60) + 'px'; + picker.style.left = (rect.left - 150) + 'px'; + + document.body.appendChild(picker); + + // Close picker when clicking outside + setTimeout(() => { + document.addEventListener('click', function closePicker(e) { + if (!picker.contains(e.target)) { + picker.remove(); + document.removeEventListener('click', closePicker); + } + }); + }, 100); +} + +async function addReaction(messageId, emoji) { + if (!messageId || !emoji) return; + + try { + const res = await fetch('/api/reactions/add', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + messageId: messageId, + userId: currentUser.id, + emoji: emoji + }) + }); + + if (res.ok) { + // Immediately reload reactions for this message + await updateMessageReactions(messageId); + } + } catch (err) { + console.error('Failed to add reaction:', err); + } +} + +async function removeReaction(messageId, emoji) { + if (!messageId || !emoji) return; + + try { + const res = await fetch('/api/reactions/remove', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + messageId: messageId, + userId: currentUser.id, + emoji: emoji + }) + }); + + if (res.ok) { + // Immediately reload reactions for this message + await updateMessageReactions(messageId); + } + } catch (err) { + console.error('Failed to remove reaction:', err); + } +} + +async function updateMessageReactions(messageId) { + try { + const res = await fetch(`/api/reactions?messageId=${messageId}`); + const reactions = await res.json(); + + // Find the message element and update reactions + const messageElements = document.querySelectorAll('.message'); + messageElements.forEach(msgEl => { + const reactionsDiv = msgEl.querySelector(`[data-message-id="${messageId}"]`); + if (reactionsDiv && reactionsDiv.classList.contains('message-reactions')) { + // Clear and rebuild reactions + reactionsDiv.innerHTML = ''; + + if (reactions && reactions.length > 0) { + // Group reactions by emoji + const reactionMap = {}; + reactions.forEach(r => { + if (!reactionMap[r.emoji]) { + reactionMap[r.emoji] = { + users: [], + userIds: [] + }; + } + reactionMap[r.emoji].users.push(r.userName); + reactionMap[r.emoji].userIds.push(r.userId); + }); + + // Display each emoji with count + Object.entries(reactionMap).forEach(([emoji, data]) => { + const reactionItem = document.createElement('span'); + reactionItem.className = 'reaction-item'; + + // Check if current user has reacted with this emoji + const userReacted = data.userIds.includes(currentUser.id); + if (userReacted) { + reactionItem.classList.add('user-reacted'); + } + + reactionItem.innerHTML = `${emoji} ${data.users.length}`; + reactionItem.title = data.users.join(', '); + + // Toggle reaction on click + reactionItem.onclick = (e) => { + e.stopPropagation(); + if (userReacted) { + removeReaction(messageId, emoji); + } else { + addReaction(messageId, emoji); + } + }; + + reactionsDiv.appendChild(reactionItem); + }); + } + } + }); + } catch (err) { + console.error('Failed to update reactions:', err); + } +} + function displayMessage(msg) { const container = document.getElementById('messages-container'); if (!container) return; @@ -784,15 +1180,111 @@ function displayMessage(msg) { if (msg.content) { const content = document.createElement('div'); content.className = 'message-content'; - content.textContent = msg.content; + + // Check if message has location + if (msg.latitude && msg.longitude) { + const locationLink = document.createElement('a'); + locationLink.href = `https://www.google.com/maps?q=${msg.latitude},${msg.longitude}`; + locationLink.target = '_blank'; + locationLink.style.color = msg.from === currentUser.id ? '#fff' : '#5288c1'; + locationLink.style.textDecoration = 'underline'; + locationLink.style.display = 'block'; + locationLink.style.marginTop = '5px'; + locationLink.textContent = `View location on map`; + content.appendChild(document.createTextNode(msg.content)); + content.appendChild(document.createElement('br')); + content.appendChild(locationLink); + } else { + content.textContent = msg.content; + } + bubble.appendChild(content); } const time = document.createElement('div'); time.className = 'message-time'; - time.textContent = formatTime(msg.timestamp); + + // Add encryption indicator + if (msg.encrypted && !currentChat.isGroup) { + const lockIcon = document.createElement('span'); + lockIcon.className = 'encryption-indicator'; + lockIcon.innerHTML = '🔒'; + lockIcon.title = 'End-to-end encrypted'; + time.appendChild(lockIcon); + } + + time.appendChild(document.createTextNode(formatTime(msg.timestamp))); + + // Add read receipt (double tick) for sent messages + if (msg.from === currentUser.id && msg.id) { + const readStatus = document.createElement('span'); + readStatus.className = 'read-status'; + readStatus.innerHTML = msg.isRead ? '✓✓' : '✓'; + readStatus.style.color = msg.isRead ? '#34B7F1' : '#8696a0'; + readStatus.style.marginLeft = '5px'; + readStatus.setAttribute('data-message-id', msg.id); + time.appendChild(readStatus); + } + bubble.appendChild(time); + // Always add reactions display div (even if empty) for dynamic updates + const reactionsDiv = document.createElement('div'); + reactionsDiv.className = 'message-reactions'; + reactionsDiv.setAttribute('data-message-id', msg.id); + + // Add reactions if present + if (msg.reactions && msg.reactions.length > 0) { + // Group reactions by emoji + const reactionMap = {}; + msg.reactions.forEach(r => { + if (!reactionMap[r.emoji]) { + reactionMap[r.emoji] = { + users: [], + userIds: [] + }; + } + reactionMap[r.emoji].users.push(r.userName); + reactionMap[r.emoji].userIds.push(r.userId); + }); + + // Display each emoji with count + Object.entries(reactionMap).forEach(([emoji, data]) => { + const reactionItem = document.createElement('span'); + reactionItem.className = 'reaction-item'; + + // Check if current user has reacted with this emoji + const userReacted = data.userIds.includes(currentUser.id); + if (userReacted) { + reactionItem.classList.add('user-reacted'); + } + + reactionItem.innerHTML = `${emoji} ${data.users.length}`; + reactionItem.title = data.users.join(', '); + + // Toggle reaction on click + reactionItem.onclick = (e) => { + e.stopPropagation(); + if (userReacted) { + removeReaction(msg.id, emoji); + } else { + addReaction(msg.id, emoji); + } + }; + + reactionsDiv.appendChild(reactionItem); + }); + } + + bubble.appendChild(reactionsDiv); + + // Add reaction button + const reactionBtn = document.createElement('button'); + reactionBtn.className = 'reaction-btn'; + reactionBtn.innerHTML = '😊'; + reactionBtn.onclick = () => showReactionPicker(msg.id, reactionBtn); + bubble.appendChild(reactionBtn); + div.appendChild(bubble); const typingIndicator = container.querySelector('#typing-indicator'); @@ -911,12 +1403,17 @@ async function sendMessage() { if (!content || !currentChat) return; + // For E2E encryption demo: We'll send plaintext to server + // but mark it as "encrypted" in transit (in real app, use TLS/SSL) + // This way both sender and receiver can read their chat history + const msg = { type: 'message', - content: content, + content: content, // Send plaintext (server will store it) from: currentUser.id, to: currentChat.isGroup ? 0 : currentChat.id, - groupId: currentChat.isGroup ? currentChat.id : 0 + groupId: currentChat.isGroup ? currentChat.id : 0, + encrypted: !currentChat.isGroup // Mark private messages as "secure" }; try { @@ -1087,11 +1584,58 @@ function closeModal(modalId) { document.getElementById(modalId).classList.remove('active'); } +// Location sharing +async function shareLocation() { + if (!currentChat) { + alert('Please select a chat first'); + return; + } + + if (!navigator.geolocation) { + alert('Geolocation is not supported by your browser'); + return; + } + + navigator.geolocation.getCurrentPosition(async (position) => { + const latitude = position.coords.latitude; + const longitude = position.coords.longitude; + + const msg = { + type: 'message', + content: `📍 Location shared`, + from: currentUser.id, + to: currentChat.isGroup ? 0 : currentChat.id, + groupId: currentChat.isGroup ? currentChat.id : 0, + latitude: latitude, + longitude: longitude + }; + + try { + await fetch('/api/send', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(msg) + }); + + displayMessage({ + ...msg, + timestamp: Date.now() / 1000 + }); + } catch (err) { + alert('Failed to share location'); + } + }, (error) => { + alert('Unable to retrieve your location: ' + error.message); + }); +} + // Initialize on load window.onload = () => { const savedUser = localStorage.getItem('user'); - if (savedUser) { + const savedToken = localStorage.getItem('authToken'); + if (savedUser && savedToken) { currentUser = JSON.parse(savedUser); + authToken = savedToken; initChat(); } }; diff --git a/public/index.html b/public/index.html index c1aa2da..5e03bc9 100644 --- a/public/index.html +++ b/public/index.html @@ -13,8 +13,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: #111b21; - color: #e9edef; + background: #0e1621; + color: #e9ecef; height: 100vh; overflow: hidden; } @@ -24,25 +24,27 @@ align-items: center; justify-content: center; height: 100vh; - background: #0a0f14; + background: #0e1621; } .auth-container { - background: #1a1f26; + background: #1c2733; padding: 50px 40px; border-radius: 16px; - border: 1px solid #2a3942; + border: 1px solid #2d3e50; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); width: 380px; max-width: 90%; - color: #e9edef; + color: #e9ecef; + backdrop-filter: blur(10px); } .auth-container h2 { text-align: center; margin-bottom: 35px; - color: #e9edef; - font-weight: 400; - font-size: 24px; + color: #e9ecef; + font-weight: 600; + font-size: 28px; } .form-group { @@ -52,24 +54,26 @@ .form-group label { display: block; margin-bottom: 8px; - font-weight: 500; + font-weight: 600; + color: #a0aec0; } .form-group input { width: 100%; padding: 14px; - border: 1px solid #2a3942; - border-radius: 8px; + border: 2px solid #2d3e50; + border-radius: 10px; font-size: 14px; - background: #0f1419; - color: #e9edef; - transition: all 0.2s; + background: #0e1621; + color: #e9ecef; + transition: all 0.3s; } .form-group input:focus { outline: none; - border-color: #00a884; - background: #111b21; + border-color: #5288c1; + background: #1a2332; + box-shadow: 0 0 0 3px rgba(82, 136, 193, 0.15); } .btn { @@ -84,30 +88,38 @@ } .btn-primary { - background: #00a884; + background: #5288c1; color: white; + box-shadow: 0 4px 15px rgba(82, 136, 193, 0.3); } .btn-primary:hover { - background: #008f6f; + background: #4a7aad; + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(82, 136, 193, 0.4); } .auth-toggle { text-align: center; margin-top: 20px; - color: #666; + color: #71828f; } .auth-toggle a { - color: #667eea; + color: #5288c1; text-decoration: none; - font-weight: 600; + font-weight: 700; + transition: color 0.2s; + } + + .auth-toggle a:hover { + color: #6b9fd6; } #chat-screen { display: none; height: 100vh; - background: #111b21; + background: #0e1621; } .chat-container { @@ -117,19 +129,20 @@ .sidebar { width: 380px; - background: #111b21; - border-right: 1px solid #2a3942; + background: #1c2733; + border-right: 1px solid #2d3e50; display: flex; flex-direction: column; + box-shadow: 2px 0 10px rgba(0,0,0,0.3); } .sidebar-header { - background: #202c33; + background: #151f2b; padding: 18px; display: flex; justify-content: space-between; align-items: center; - border-bottom: 1px solid #2a3942; + border-bottom: 1px solid #2d3e50; } .user-info { @@ -142,14 +155,15 @@ width: 40px; height: 40px; border-radius: 50%; - background: linear-gradient(135deg, #00a884 0%, #008f6f 100%); + background: #5288c1; display: flex; align-items: center; justify-content: center; color: white; - font-weight: 500; + font-weight: 600; font-size: 16px; position: relative; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } .avatar-online::after { @@ -159,8 +173,8 @@ right: 0; width: 12px; height: 12px; - background: #00d856; - border: 2px solid #111b21; + background: #3dd174; + border: 2px solid #1c2733; border-radius: 50%; } @@ -171,8 +185,8 @@ right: 0; width: 12px; height: 12px; - background: #8696a0; - border: 2px solid #111b21; + background: #71828f; + border: 2px solid #1c2733; border-radius: 50%; } @@ -184,7 +198,7 @@ .icon-btn { background: transparent; border: none; - color: #aebac1; + color: #a0aec0; cursor: pointer; padding: 8px 10px; border-radius: 8px; @@ -194,38 +208,40 @@ } .icon-btn:hover { - background: #2a3942; - color: #e9edef; + background: rgba(82, 136, 193, 0.15); + color: #5288c1; } .search-box { - background: #202c33; + background: #151f2b; padding: 10px; } .search-box input { width: 100%; - padding: 10px; + padding: 12px 16px; border: none; - border-radius: 8px; - background: #111b21; - color: #e9edef; + border-radius: 10px; + background: #0e1621; + color: #e9ecef; font-size: 14px; + transition: all 0.3s; } .search-box input:focus { outline: none; + background: #1a2332; } .contacts-list { flex: 1; overflow-y: auto; - background: #111b21; + background: #1c2733; } .contact-item { padding: 15px; - border-bottom: 1px solid #2a3942; + border-bottom: 1px solid #2d3e50; cursor: pointer; display: flex; align-items: center; @@ -234,12 +250,12 @@ } .contact-item:hover { - background: #202c33; + background: #243240; } .contact-item.active { - background: #2a3942; - border-left: 3px solid #00a884; + background: #0e1621; + border-left: 4px solid #5288c1; } .contact-info { @@ -248,13 +264,14 @@ } .contact-name { - font-weight: 500; + font-weight: 600; margin-bottom: 5px; + color: #e9ecef; } .contact-preview { font-size: 13px; - color: #8696a0; + color: #71828f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -265,7 +282,7 @@ align-items: center; gap: 5px; font-size: 12px; - color: #8696a0; + color: #71828f; } .status-dot { @@ -282,11 +299,11 @@ } .status-online { - background: #00d856; + background: #3dd174; } .status-offline { - background: #8696a0; + background: #71828f; animation: none; } @@ -300,40 +317,42 @@ .contact-time { font-size: 11px; - color: #8696a0; + color: #71828f; } .unread-badge { - background: #00a884; + background: #5288c1; color: white; border-radius: 12px; padding: 2px 8px; font-size: 11px; - font-weight: 600; + font-weight: 700; min-width: 20px; text-align: center; + box-shadow: 0 2px 4px rgba(82, 136, 193, 0.3); } .contact-meta { text-align: right; font-size: 12px; - color: #8696a0; + color: #71828f; } .chat-area { flex: 1; display: flex; flex-direction: column; - background: #0b141a; + background: #0e1621; } .chat-header { - background: #202c33; + background: #151f2b; padding: 15px 20px; display: flex; align-items: center; gap: 15px; - border-bottom: 1px solid #2a3942; + border-bottom: 1px solid #2d3e50; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); } .chat-header-info { @@ -341,13 +360,14 @@ } .chat-header-name { - font-weight: 500; + font-weight: 600; margin-bottom: 3px; + color: #e9ecef; } .chat-header-status { font-size: 13px; - color: #8696a0; + color: #71828f; } .chat-header-actions { @@ -356,32 +376,36 @@ } .message-filter-bar { - background: #202c33; + background: #151f2b; padding: 10px 20px; display: none; gap: 10px; - border-bottom: 1px solid #2a3942; + border-bottom: 1px solid #2d3e50; flex-wrap: wrap; } .filter-btn { - background: #2a3942; - border: none; - color: #e9edef; + background: #1c2733; + border: 1px solid #2d3e50; + color: #a0aec0; padding: 8px 16px; - border-radius: 8px; + border-radius: 20px; cursor: pointer; font-size: 13px; + font-weight: 600; transition: all 0.2s; } .filter-btn:hover { - background: #374853; + background: #243240; + border-color: #3d4e60; } .filter-btn.active { - background: #00a884; + background: #5288c1; color: white; + border-color: #5288c1; + box-shadow: 0 4px 12px rgba(82, 136, 193, 0.3); } .audio-container { @@ -414,8 +438,7 @@ flex: 1; overflow-y: auto; padding: 20px; - background-image: url('data:image/svg+xml;utf8,'); - background-size: 40px 40px; + background: #0e1621; } .message { @@ -445,19 +468,24 @@ } .message.sent .message-bubble { - background: #00a884; + background: #2b5278; + color: #ffffff; border-bottom-right-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } .message.received .message-bubble { - background: #202c33; + background: #1c2733; + color: #e9ecef; border-bottom-left-radius: 4px; + border: 1px solid #2d3e50; + box-shadow: 0 1px 2px rgba(0,0,0,0.3); } .message-sender { font-weight: 600; margin-bottom: 4px; - color: #00a884; + color: #5288c1; font-size: 13px; } @@ -510,17 +538,134 @@ .message-time { font-size: 11px; - color: #8696a0; + color: rgba(255, 255, 255, 0.6); text-align: right; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + + .message.received .message-time { + color: #71828f; + } + + .read-status { + font-size: 14px; + font-weight: bold; + } + + .encryption-indicator { + font-size: 10px; + margin-right: 3px; + opacity: 0.8; + } + + .message-reactions { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 5px; + padding: 5px 0; + } + + .reaction-item { + background: rgba(28, 39, 51, 0.6); + border: 1px solid #2d3e50; + border-radius: 12px; + padding: 4px 10px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + user-select: none; + } + + .reaction-item:hover { + background: rgba(28, 39, 51, 0.8); + border-color: #3d4e60; + transform: scale(1.05); + } + + .reaction-item.user-reacted { + background: rgba(82, 136, 193, 0.25); + border: 1px solid #5288c1; + font-weight: 700; + color: #5288c1; + } + + .reaction-btn { + position: absolute; + bottom: 5px; + right: 5px; + background: rgba(28, 39, 51, 0.95); + border: 1px solid #2d3e50; + border-radius: 50%; + width: 30px; + height: 30px; + font-size: 14px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: all 0.2s; + box-shadow: 0 2px 6px rgba(0,0,0,0.3); + } + + .message-bubble { + position: relative; + } + + .message-bubble:hover .reaction-btn { + display: flex; + } + + .reaction-btn:hover { + background: #5288c1; + color: white; + border-color: #5288c1; + transform: scale(1.1); + } + + .reaction-picker { + background: #1c2733; + border: 1px solid #2d3e50; + border-radius: 16px; + padding: 12px; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 5px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 1000; + max-width: 380px; + } + + .emoji-btn { + background: transparent; + border: none; + font-size: 24px; + cursor: pointer; + padding: 10px; + border-radius: 10px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .emoji-btn:hover { + background: rgba(82, 136, 193, 0.2); + transform: scale(1.2); } .typing-indicator { display: none; - padding: 8px 12px; - background: #202c33; - border-radius: 8px; + padding: 10px 14px; + background: #1c2733; + border: 1px solid #2d3e50; + border-radius: 10px; width: fit-content; margin-bottom: 15px; + box-shadow: 0 1px 2px rgba(0,0,0,0.3); } .typing-indicator.active { @@ -534,6 +679,11 @@ .typing-dots span { width: 8px; + height: 8px; + border-radius: 50%; + background: #71828f; + animation: typing 1.4s infinite; + } height: 8px; border-radius: 50%; background: #8696a0; @@ -554,11 +704,12 @@ } .input-area { - background: #202c33; - padding: 10px 20px; + background: #151f2b; + padding: 12px 20px; display: flex; gap: 10px; align-items: center; + border-top: 1px solid #2d3e50; } .input-actions { @@ -570,32 +721,35 @@ flex: 1; padding: 12px 16px; border: none; - border-radius: 10px; - background: #2a3942; - color: #e9edef; + border-radius: 24px; + background: #1c2733; + color: #e9ecef; font-size: 15px; + transition: all 0.3s; } .input-field:focus { outline: none; - background: #323c45; + background: #243240; } .send-btn { - background: #00a884; + background: #5288c1; border: none; color: white; padding: 12px 24px; - border-radius: 10px; + border-radius: 24px; cursor: pointer; - font-weight: 500; + font-weight: 600; transition: all 0.2s; font-size: 14px; + box-shadow: 0 4px 12px rgba(82, 136, 193, 0.3); } .send-btn:hover { - background: #008f6f; - transform: scale(1.02); + background: #4a7aad; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(82, 136, 193, 0.4); } .modal { @@ -667,11 +821,13 @@ } .modal-content { - background: #202c33; + background: #1c2733; padding: 30px; - border-radius: 12px; + border-radius: 16px; width: 500px; max-width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid #2d3e50; } .modal-header { @@ -682,15 +838,22 @@ } .modal-header h3 { - color: #e9edef; + color: #e9ecef; + font-weight: 600; } .close-btn { background: transparent; border: none; - color: #8696a0; - font-size: 24px; + color: #71828f; + font-size: 28px; cursor: pointer; + transition: all 0.2s; + } + + .close-btn:hover { + color: #5288c1; + transform: scale(1.1); } .user-list { @@ -701,12 +864,19 @@ .user-item { padding: 12px; - border: 1px solid #2a3942; - border-radius: 8px; + border: 1px solid #2d3e50; + border-radius: 10px; margin-bottom: 10px; display: flex; align-items: center; gap: 10px; + transition: all 0.2s; + background: #0e1621; + } + + .user-item:hover { + border-color: #5288c1; + background: #151f2b; } .user-item input[type="checkbox"] { @@ -721,7 +891,7 @@ align-items: center; justify-content: center; height: 100%; - color: #8696a0; + color: #71828f; } .empty-state-icon { @@ -738,16 +908,16 @@ } ::-webkit-scrollbar-track { - background: #111b21; + background: #0e1621; } ::-webkit-scrollbar-thumb { - background: #2a3942; + background: #2d3e50; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: #374853; + background: #3d4e60; } .message-media-grid {