├── .gitignore ├── .dockerignore ├── go.mod ├── web ├── static │ ├── favicon.png │ ├── styles.css │ └── chat.js └── templates │ └── index.html ├── go.sum ├── docker-compose.yml ├── Dockerfile ├── cmd └── server │ └── main.go ├── internal └── chat │ ├── room.go │ ├── client.go │ ├── client_test.go │ ├── server_test.go │ └── server.go ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .DS_Store 4 | docker-compose.yml 5 | Dockerfile -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module chat 2 | 3 | go 1.21 4 | 5 | require github.com/gorilla/websocket v1.5.0 6 | -------------------------------------------------------------------------------- /web/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deiu/chat/main/web/static/favicon.png -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 2 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Andrei Sambra 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # Unless required by applicable law or agreed to in writing, software 7 | # distributed under the License is distributed on an "AS IS" BASIS, 8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 9 | # See the License for the specific language governing permissions and 10 | # limitations under the License. 11 | 12 | version: '3.8' 13 | 14 | services: 15 | chat: 16 | build: . 17 | ports: 18 | - "8080:8080" 19 | restart: unless-stopped -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Andrei Sambra 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # http://www.apache.org/licenses/LICENSE-2.0 6 | # Unless required by applicable law or agreed to in writing, software 7 | # distributed under the License is distributed on an "AS IS" BASIS, 8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 9 | # See the License for the specific language governing permissions and 10 | # limitations under the License. 11 | 12 | # Build stage 13 | FROM golang:1.21-alpine AS builder 14 | 15 | WORKDIR /app 16 | 17 | # Copy go mod files 18 | COPY go.mod go.sum ./ 19 | RUN go mod download 20 | 21 | # Copy source code 22 | COPY . . 23 | 24 | # Build the application 25 | RUN CGO_ENABLED=0 GOOS=linux go build -o /chat-server ./cmd/server 26 | 27 | # Final stage 28 | FROM alpine:latest 29 | 30 | WORKDIR /app 31 | 32 | # Copy the binary from builder 33 | COPY --from=builder /chat-server . 34 | # Copy web assets 35 | COPY web/ ./web/ 36 | 37 | EXPOSE 8080 38 | 39 | CMD ["./chat-server"] -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "chat/internal/chat" 21 | "log" 22 | "net/http" 23 | ) 24 | 25 | func main() { 26 | server := chat.NewChatServer() 27 | 28 | // Serve static files 29 | fs := http.FileServer(http.Dir("web/static")) 30 | http.Handle("/static/", http.StripPrefix("/static/", fs)) 31 | 32 | // Serve the main page 33 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 34 | http.ServeFile(w, r, "web/templates/index.html") 35 | }) 36 | 37 | // Handle WebSocket connections 38 | http.HandleFunc("/ws", server.HandleWebSocket) 39 | 40 | // Handle getting online users 41 | http.HandleFunc("/users", server.HandleGetOnlineUsers) 42 | 43 | log.Println("Server starting on :8080") 44 | if err := http.ListenAndServe(":8080", nil); err != nil { 45 | log.Fatal("ListenAndServe: ", err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/chat/room.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package chat 18 | 19 | import ( 20 | "sync" 21 | ) 22 | 23 | type ChatRoom struct { 24 | clients map[*Client]bool 25 | mu sync.Mutex 26 | } 27 | 28 | func NewChatRoom() *ChatRoom { 29 | return &ChatRoom{ 30 | clients: make(map[*Client]bool), 31 | } 32 | } 33 | 34 | func (cr *ChatRoom) AddClient(client *Client) { 35 | cr.mu.Lock() 36 | defer cr.mu.Unlock() 37 | cr.clients[client] = true 38 | } 39 | 40 | func (cr *ChatRoom) RemoveClient(client *Client) { 41 | cr.mu.Lock() 42 | defer cr.mu.Unlock() 43 | delete(cr.clients, client) 44 | } 45 | 46 | func (cr *ChatRoom) GetClients() map[*Client]bool { 47 | cr.mu.Lock() 48 | defer cr.mu.Unlock() 49 | // Return a copy to prevent external modifications 50 | clientsCopy := make(map[*Client]bool) 51 | for client, value := range cr.clients { 52 | clientsCopy[client] = value 53 | } 54 | return clientsCopy 55 | } 56 | -------------------------------------------------------------------------------- /internal/chat/client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package chat 18 | 19 | import "github.com/gorilla/websocket" 20 | 21 | type Client struct { 22 | conn *websocket.Conn 23 | username string 24 | sendChan chan Message 25 | } 26 | 27 | type Message struct { 28 | Type string `json:"type,omitempty"` 29 | From string `json:"from,omitempty"` 30 | To string `json:"to,omitempty"` 31 | Content string `json:"content,omitempty"` 32 | Username string `json:"username,omitempty"` // For logout notifications 33 | } 34 | 35 | func (c *Client) writePump() { 36 | defer func() { 37 | c.conn.Close() 38 | }() 39 | 40 | for message := range c.sendChan { 41 | err := c.conn.WriteJSON(message) 42 | if err != nil { 43 | return 44 | } 45 | } 46 | } 47 | 48 | func (c *Client) readPump(s *ChatServer) { 49 | defer func() { 50 | s.mu.Lock() 51 | delete(s.clients, c.username) 52 | s.mu.Unlock() 53 | c.conn.Close() 54 | }() 55 | 56 | for { 57 | var message Message 58 | err := c.conn.ReadJSON(&message) 59 | if err != nil { 60 | break 61 | } 62 | 63 | message.From = c.username 64 | s.mu.Lock() 65 | if targetClient, exists := s.clients[message.To]; exists { 66 | select { 67 | case targetClient.sendChan <- message: 68 | default: 69 | close(targetClient.sendChan) 70 | delete(s.clients, targetClient.username) 71 | } 72 | } 73 | s.mu.Unlock() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat 2 | 3 | A real-time chat application with direct messaging support, built with Go and WebSocket. 4 | 5 | You can see a live demo at https://deiu-chat.onrender.com but you may have to wait up to a minutefor the server to restart after a long period of inactivity. 6 | 7 | ## Features 8 | 9 | - Real-time messaging using WebSocket 10 | - Direct user-to-user messaging 11 | - Case-insensitive unique usernames 12 | - Unread message notifications 13 | - Online users list 14 | - Docker support 15 | - Clean disconnection handling 16 | - Message history per conversation 17 | 18 | ## Usage 19 | 20 | 1. Open the application in your browser (default: http://localhost:8080) 21 | 2. Enter a username to login 22 | 3. Select a user from the online users list to start chatting 23 | 4. Messages are delivered in real-time 24 | 5. Unread messages are indicated with (*) 25 | 6. Use the logout button to disconnect 26 | 27 | ## Development 28 | 29 | ### Prerequisites 30 | 31 | - Go 1.21 or later 32 | - Docker (optional) 33 | 34 | ### Building the application 35 | 36 | ```bash 37 | go build -o chat main.go 38 | ``` 39 | 40 | ### Running the application 41 | 42 | ```bash 43 | ./chat 44 | ``` 45 | 46 | ### Using Docker 47 | 48 | 1. Clone the repository: 49 | 50 | ```bash 51 | git clone https://github.com/deiu/chat.git 52 | cd chat 53 | ``` 54 | 55 | 2. Build and run the application: 56 | 57 | ```bash 58 | docker build -t chat . 59 | docker run -p 8080:8080 chat 60 | ``` 61 | 62 | 3. Access the application in your browser at `http://localhost:8080`. 63 | 64 | ### Using Docker Compose 65 | 66 | 1. Clone the repository: 67 | 68 | ```bash 69 | git clone https://github.com/deiu/chat.git 70 | cd chat 71 | ``` 72 | 73 | 2. Build and run the application: 74 | 75 | ```bash 76 | docker-compose up --build 77 | ``` 78 | 79 | 3. Access the application in your browser at `http://localhost:8080`. 80 | 81 | ## Contributing 82 | 83 | 1. Fork the repository 84 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 85 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 86 | 4. Push to the branch (`git push origin feature/amazing-feature`) 87 | 5. Open a Pull Request 88 | 89 | ## License 90 | 91 | Apache License 2.0 - see [LICENSE](LICENSE) for details -------------------------------------------------------------------------------- /web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Direct Chat 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 |
16 | 29 | 30 |
31 |
32 |

33 | 34 | 35 | 36 |

37 |
38 |
39 |

Welcome to Direct Chat

40 |

Select a user from the Online Users list to start chatting

41 |
42 |
43 |
44 |
45 | 46 | 47 |
48 |
49 |
50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /internal/chat/client_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package chat 18 | 19 | import ( 20 | "encoding/json" 21 | "testing" 22 | "time" 23 | ) 24 | 25 | func TestMessageSerialization(t *testing.T) { 26 | tests := []struct { 27 | name string 28 | message Message 29 | }{ 30 | { 31 | name: "regular message", 32 | message: Message{ 33 | From: "user1", 34 | To: "user2", 35 | Content: "Hello", 36 | }, 37 | }, 38 | { 39 | name: "logout message", 40 | message: Message{ 41 | Type: "logout", 42 | Username: "user1", 43 | }, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | // Marshal 50 | data, err := json.Marshal(tt.message) 51 | if err != nil { 52 | t.Fatalf("failed to marshal message: %v", err) 53 | } 54 | 55 | // Unmarshal 56 | var decoded Message 57 | err = json.Unmarshal(data, &decoded) 58 | if err != nil { 59 | t.Fatalf("failed to unmarshal message: %v", err) 60 | } 61 | 62 | // Compare 63 | if decoded.Type != tt.message.Type || 64 | decoded.From != tt.message.From || 65 | decoded.To != tt.message.To || 66 | decoded.Content != tt.message.Content || 67 | decoded.Username != tt.message.Username { 68 | t.Errorf("decoded message = %+v, want %+v", decoded, tt.message) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | func TestClientMessageBuffering(t *testing.T) { 75 | server := NewChatServer() 76 | conn1, s1 := setupWebSocketConnection(t, server, "sender") 77 | defer conn1.Close() 78 | defer s1.Close() 79 | 80 | conn2, s2 := setupWebSocketConnection(t, server, "receiver") 81 | defer conn2.Close() 82 | defer s2.Close() 83 | 84 | // Send multiple messages 85 | messages := []string{ 86 | "Message 1", 87 | "Message 2", 88 | "Message 3", 89 | } 90 | 91 | for _, msg := range messages { 92 | err := conn1.WriteJSON(Message{ 93 | To: "receiver", 94 | Content: msg, 95 | }) 96 | if err != nil { 97 | t.Fatalf("failed to send message: %v", err) 98 | } 99 | } 100 | 101 | // Verify all messages are received in order 102 | for _, want := range messages { 103 | // Read messages until we get a chat message 104 | for { 105 | var raw json.RawMessage 106 | err := conn2.ReadJSON(&raw) 107 | if err != nil { 108 | t.Fatalf("failed to receive message: %v", err) 109 | } 110 | 111 | // Try to decode as Message 112 | var msg Message 113 | if err := json.Unmarshal(raw, &msg); err == nil && msg.Content != "" { 114 | if msg.Content != want { 115 | t.Errorf("received message = %v, want %v", msg.Content, want) 116 | } 117 | break 118 | } 119 | // If not a chat message, continue reading 120 | } 121 | } 122 | } 123 | 124 | func TestClientDisconnection(t *testing.T) { 125 | server := NewChatServer() 126 | 127 | // Connect a client 128 | conn, s := setupWebSocketConnection(t, server, "disconnectuser") 129 | defer s.Close() 130 | 131 | // Close the connection 132 | conn.Close() 133 | 134 | // Give some time for the server to process the disconnection 135 | time.Sleep(100 * time.Millisecond) 136 | 137 | // Verify the client was removed from the server 138 | server.mu.Lock() 139 | if _, exists := server.clients["disconnectuser"]; exists { 140 | t.Error("client was not removed after disconnection") 141 | } 142 | server.mu.Unlock() 143 | } 144 | -------------------------------------------------------------------------------- /internal/chat/server_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package chat 18 | 19 | import ( 20 | "encoding/json" 21 | "net/http" 22 | "net/http/httptest" 23 | "strings" 24 | "testing" 25 | "time" 26 | 27 | "github.com/gorilla/websocket" 28 | ) 29 | 30 | func TestNewChatServer(t *testing.T) { 31 | server := NewChatServer() 32 | if server == nil { 33 | t.Fatal("NewChatServer returned nil") 34 | } 35 | if server.clients == nil { 36 | t.Error("clients map not initialized") 37 | } 38 | } 39 | 40 | func TestHandleWebSocketUsernameValidation(t *testing.T) { 41 | server := NewChatServer() 42 | 43 | tests := []struct { 44 | name string 45 | username string 46 | wantErr bool 47 | }{ 48 | {"empty username", "", true}, 49 | {"valid username", "testuser", false}, 50 | {"duplicate username", "testuser", true}, 51 | {"case insensitive duplicate", "TestUser", true}, 52 | } 53 | 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | server.HandleWebSocket(w, r) 58 | })) 59 | defer s.Close() 60 | 61 | // Convert http to ws 62 | wsURL := "ws" + strings.TrimPrefix(s.URL, "http") + "?username=" + tt.username 63 | 64 | _, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 65 | if (err != nil) != tt.wantErr { 66 | t.Errorf("Dial() error = %v, wantErr %v", err, tt.wantErr) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func setupWebSocketConnection(t *testing.T, server *ChatServer, username string) (*websocket.Conn, *httptest.Server) { 73 | t.Helper() 74 | 75 | var conn *websocket.Conn 76 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 77 | server.HandleWebSocket(w, r) 78 | })) 79 | 80 | // Convert http to ws 81 | wsURL := "ws" + strings.TrimPrefix(s.URL, "http") + "?username=" + username 82 | 83 | // Connect to the server 84 | ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 85 | if err != nil { 86 | t.Fatalf("could not open websocket connection: %v", err) 87 | } 88 | conn = ws 89 | 90 | return conn, s 91 | } 92 | 93 | func TestWebSocketCommunication(t *testing.T) { 94 | server := NewChatServer() 95 | 96 | // Connect first client 97 | conn1, s1 := setupWebSocketConnection(t, server, "user1") 98 | defer s1.Close() 99 | defer conn1.Close() 100 | 101 | // Connect second client 102 | conn2, s2 := setupWebSocketConnection(t, server, "user2") 103 | defer s2.Close() 104 | defer conn2.Close() 105 | 106 | // Set read deadline 107 | conn2.SetReadDeadline(time.Now().Add(time.Second)) 108 | 109 | // Test sending message 110 | message := Message{ 111 | To: "user2", 112 | Content: "Hello", 113 | } 114 | 115 | if err := conn1.WriteJSON(message); err != nil { 116 | t.Fatalf("could not send message: %v", err) 117 | } 118 | 119 | // Read messages until we get our test message 120 | conn2.SetReadDeadline(time.Now().Add(time.Second)) 121 | for { 122 | var raw json.RawMessage 123 | if err := conn2.ReadJSON(&raw); err != nil { 124 | t.Fatalf("could not receive message: %v", err) 125 | } 126 | 127 | // Try to decode as Message 128 | var received Message 129 | if err := json.Unmarshal(raw, &received); err == nil && received.Content == message.Content { 130 | // Found our message 131 | if received.Content != message.Content { 132 | t.Errorf("received message content = %v, want %v", received.Content, message.Content) 133 | } 134 | break 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /internal/chat/server.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package chat 18 | 19 | import ( 20 | "encoding/json" 21 | "log" 22 | "net/http" 23 | "strings" 24 | "sync" 25 | 26 | "github.com/gorilla/websocket" 27 | ) 28 | 29 | type ChatServer struct { 30 | clients map[string]*Client // key is username 31 | logoutChan chan string 32 | upgrader websocket.Upgrader 33 | mu sync.Mutex 34 | } 35 | 36 | type OnlineUser struct { 37 | Username string `json:"username"` 38 | } 39 | 40 | func NewChatServer() *ChatServer { 41 | return &ChatServer{ 42 | clients: make(map[string]*Client), 43 | upgrader: websocket.Upgrader{ 44 | CheckOrigin: func(r *http.Request) bool { 45 | return true 46 | }, 47 | }, 48 | logoutChan: make(chan string), 49 | } 50 | } 51 | 52 | func (s *ChatServer) HandleWebSocket(w http.ResponseWriter, r *http.Request) { 53 | username := r.URL.Query().Get("username") 54 | if username == "" { 55 | http.Error(w, "Username is required", http.StatusBadRequest) 56 | return 57 | } 58 | 59 | // Check for username uniqueness case-insensitively 60 | s.mu.Lock() 61 | lowercaseUsername := strings.ToLower(username) 62 | for existingUser := range s.clients { 63 | if strings.ToLower(existingUser) == lowercaseUsername { 64 | s.mu.Unlock() 65 | http.Error(w, "Username already taken", http.StatusConflict) 66 | return 67 | } 68 | } 69 | 70 | conn, err := s.upgrader.Upgrade(w, r, nil) 71 | if err != nil { 72 | s.mu.Unlock() 73 | log.Printf("Websocket upgrade error: %v", err) 74 | return 75 | } 76 | 77 | client := &Client{ 78 | conn: conn, 79 | username: username, // Keep original case for display 80 | sendChan: make(chan Message, 256), 81 | } 82 | 83 | s.clients[username] = client 84 | s.mu.Unlock() 85 | 86 | go func() { 87 | username := <-s.logoutChan 88 | if username == client.username { 89 | s.removeClient(username) 90 | conn.Close() 91 | } 92 | }() 93 | 94 | go s.broadcastOnlineUsers() 95 | go client.writePump() 96 | go client.readPump(s) 97 | } 98 | 99 | // Add new method to handle logout 100 | func (s *ChatServer) HandleLogout(username string) { 101 | s.mu.Lock() 102 | if client, exists := s.clients[username]; exists { 103 | client.conn.Close() 104 | delete(s.clients, username) 105 | } 106 | s.mu.Unlock() 107 | 108 | // Broadcast updated online users list 109 | s.broadcastOnlineUsers() 110 | } 111 | 112 | func (s *ChatServer) HandleGetOnlineUsers(w http.ResponseWriter, r *http.Request) { 113 | s.mu.Lock() 114 | users := make([]OnlineUser, 0, len(s.clients)) 115 | for username := range s.clients { 116 | users = append(users, OnlineUser{Username: username}) 117 | } 118 | s.mu.Unlock() 119 | 120 | w.Header().Set("Content-Type", "application/json") 121 | json.NewEncoder(w).Encode(users) 122 | } 123 | 124 | func (s *ChatServer) broadcastOnlineUsers() { 125 | s.mu.Lock() 126 | users := make([]OnlineUser, 0, len(s.clients)) 127 | for username := range s.clients { 128 | users = append(users, OnlineUser{Username: username}) 129 | } 130 | s.mu.Unlock() 131 | 132 | usersJSON, _ := json.Marshal(users) 133 | 134 | s.mu.Lock() 135 | for _, client := range s.clients { 136 | client.conn.WriteMessage(websocket.TextMessage, usersJSON) 137 | } 138 | s.mu.Unlock() 139 | } 140 | 141 | func (s *ChatServer) removeClient(username string) { 142 | s.mu.Lock() 143 | if client, exists := s.clients[username]; exists { 144 | delete(s.clients, username) 145 | close(client.sendChan) 146 | } 147 | s.mu.Unlock() 148 | 149 | // Notify all clients about the logout 150 | s.mu.Lock() 151 | logoutMsg := Message{ 152 | Type: "logout", 153 | Username: username, 154 | } 155 | msgBytes, _ := json.Marshal(logoutMsg) 156 | for _, client := range s.clients { 157 | client.conn.WriteMessage(websocket.TextMessage, msgBytes) 158 | } 159 | s.mu.Unlock() 160 | 161 | // Broadcast updated user list 162 | s.broadcastOnlineUsers() 163 | } 164 | -------------------------------------------------------------------------------- /web/static/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 6 | } 7 | 8 | body { 9 | background-color: #f5f5f8; 10 | } 11 | 12 | .container { 13 | display: flex; 14 | height: 100vh; 15 | max-width: 1200px; 16 | margin: 0 auto; 17 | background: white; 18 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); 19 | display: none; 20 | } 21 | 22 | .sidebar { 23 | width: 280px; 24 | border-right: 1px solid #e2e8f0; 25 | background: #f3f4f6; 26 | display: flex; 27 | flex-direction: column; 28 | } 29 | 30 | .user-section { 31 | padding: 15px 20px; 32 | border-bottom: 1px solid #e2e8f0; 33 | height: 60px; 34 | min-height: 60px; /* Ensure minimum height */ 35 | display: flex; 36 | align-items: center; 37 | position: sticky; /* Keep at top */ 38 | top: 0; 39 | background: white; /* Ensure background is solid */ 40 | z-index: 10; 41 | } 42 | 43 | .current-user { 44 | font-size: 1.2em; 45 | color: #2c5282; 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | width: 100%; 50 | } 51 | 52 | .online-users { 53 | padding: 15px 20px; 54 | } 55 | 56 | .online-users h3 { 57 | margin-bottom: 10px; 58 | } 59 | 60 | .user-item { 61 | padding: 8px 12px; 62 | cursor: pointer; 63 | display: flex; 64 | align-items: center; 65 | border-radius: 6px; 66 | margin: 2px 0; 67 | } 68 | 69 | .user-item:hover { 70 | color: #ffffff; 71 | background-color: #232e44; 72 | } 73 | 74 | .user-item.active { 75 | color: #ffffff; 76 | background-color: #232e44; 77 | font-weight: 500; 78 | } 79 | 80 | .user-item.has-unread::after { 81 | content: '(*)'; 82 | color: #e53e3e; 83 | margin-left: 5px; 84 | animation: blink 1s infinite; 85 | } 86 | 87 | .chat-area { 88 | flex-grow: 1; 89 | display: flex; 90 | flex-direction: column; 91 | height: 100vh; 92 | overflow: hidden; 93 | } 94 | 95 | .chat-header { 96 | padding: 15px 20px; 97 | border-bottom: 1px solid #e2e8f0; 98 | height: 60px; 99 | min-height: 60px; 100 | width: 100%; 101 | display: flex; 102 | align-items: center; 103 | position: sticky; 104 | top: 0; 105 | background: white; 106 | z-index: 10; 107 | } 108 | 109 | .chat-header h2 { 110 | font-size: 1.1em; 111 | font-weight: 600; 112 | display: flex; 113 | align-items: center; 114 | gap: 10px; 115 | } 116 | 117 | .chat-header h2 #currentRecipient { 118 | display: inline; 119 | } 120 | 121 | .conversation { 122 | flex-grow: 1; 123 | padding: 20px; 124 | overflow-y: auto; 125 | display: none; 126 | } 127 | 128 | .message { 129 | margin-bottom: 20px; 130 | display: flex; 131 | gap: 12px; 132 | } 133 | 134 | .message.own { 135 | margin-left: auto; 136 | text-align: right; 137 | } 138 | 139 | .message.own .message-content { 140 | background-color: #4299e1; 141 | color: white; 142 | } 143 | 144 | .message .username { 145 | font-weight: 600; 146 | color: #2c5282; 147 | margin-bottom: 4px; 148 | } 149 | 150 | .message-content { 151 | background-color: #f7fafc; 152 | padding: 10px; 153 | border-radius: 6px; 154 | line-height: 1.4; 155 | } 156 | 157 | .message-content-wrapper { 158 | flex-grow: 1; 159 | } 160 | 161 | .message-header { 162 | display: flex; 163 | align-items: baseline; 164 | gap: 8px; 165 | margin-bottom: 4px; 166 | } 167 | 168 | .message-sender { 169 | font-weight: 600; 170 | color: #111827; 171 | } 172 | 173 | .message-time { 174 | color: #6B7280; 175 | font-size: 0.875em; 176 | } 177 | 178 | .message-text { 179 | color: #111827; 180 | line-height: 1.5; 181 | } 182 | 183 | .user-avatar { 184 | width: 36px; 185 | height: 36px; 186 | border-radius: 50%; 187 | display: flex; 188 | align-items: center; 189 | justify-content: center; 190 | font-weight: 600; 191 | color: white; 192 | flex-shrink: 0; 193 | } 194 | 195 | .input-area { 196 | padding: 15px 20px; 197 | border-top: 1px solid #e2e8f0; 198 | display: flex; 199 | align-items: center; 200 | gap: 10px; 201 | height: 60px; 202 | min-height: 60px; 203 | width: 100%; 204 | background: white; 205 | position: sticky; 206 | bottom: 0; 207 | z-index: 10; 208 | } 209 | 210 | .text-input { 211 | padding: 8px 12px; 212 | border: 1px solid #e2e8f0; 213 | border-radius: 6px; 214 | outline: none; 215 | background-color: #f7fafc; 216 | height: 40px; 217 | font-size: 16px; 218 | } 219 | 220 | .text-input:focus { 221 | border-color: #4299e1; 222 | } 223 | 224 | #messageInput { 225 | flex-grow: 1; 226 | } 227 | 228 | #usernameInput { 229 | min-width: 200px; 230 | } 231 | 232 | button { 233 | padding: 8px 16px; 234 | border: none; 235 | border-radius: 6px; 236 | background-color: #4299e1; 237 | color: white; 238 | cursor: pointer; 239 | height: 40px; 240 | font-size: 16px; 241 | } 242 | 243 | button:hover { 244 | background-color: #3182ce; 245 | } 246 | 247 | button:disabled { 248 | background-color: #cbd5e0; 249 | cursor: not-allowed; 250 | } 251 | 252 | .logout-btn { 253 | background-color: #e53e3e; 254 | margin-left: auto; 255 | } 256 | 257 | .logout-btn:hover { 258 | background-color: #c53030; 259 | } 260 | 261 | #login { 262 | position: fixed; 263 | top: 50%; 264 | left: 50%; 265 | transform: translate(-50%, -50%); 266 | background: white; 267 | padding: 30px; 268 | border-radius: 8px; 269 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); 270 | display: flex; 271 | gap: 10px; 272 | } 273 | 274 | .placeholder-screen { 275 | flex-grow: 1; 276 | display: flex; 277 | flex-direction: column; 278 | align-items: center; 279 | justify-content: center; 280 | color: #718096; 281 | padding: 20px; 282 | text-align: center; 283 | height: calc(100vh - 120px); 284 | } 285 | 286 | .placeholder-screen h2 { 287 | font-size: 1.5em; 288 | margin-bottom: 10px; 289 | color: #4a5568; 290 | } 291 | 292 | .placeholder-screen p { 293 | font-size: 1.1em; 294 | color: #718096; 295 | } 296 | 297 | .chat-header h2 span { 298 | display: none; 299 | } 300 | 301 | .chat-content { 302 | display: none; 303 | flex-direction: column; 304 | flex-grow: 1; 305 | overflow-y: hidden; 306 | } 307 | 308 | .chat-content.show { 309 | display: flex; 310 | } 311 | 312 | .chat-content.show + .placeholder-screen { 313 | display: none; 314 | } 315 | 316 | .chat-content.show ~ .chat-header h2 span { 317 | display: inline; 318 | } 319 | 320 | #conversations { 321 | flex-grow: 1; 322 | overflow-y: auto; 323 | padding: 20px; 324 | } 325 | 326 | @keyframes blink { 327 | 0% { opacity: 1; } 328 | 50% { opacity: 0.5; } 329 | 100% { opacity: 1; } 330 | } 331 | 332 | .menu-toggle { 333 | display: none; 334 | background: none; 335 | border: none; 336 | padding: 8px; 337 | color: #2c5282; 338 | cursor: pointer; 339 | font-size: 1.2em; 340 | } 341 | 342 | .menu-toggle:hover { 343 | background: none; 344 | color: #4299e1; 345 | } 346 | 347 | .close-sidebar { 348 | display: none; 349 | background: none; 350 | border: none; 351 | color: #2c5282; 352 | font-size: 1.2em; 353 | cursor: pointer; 354 | padding: 8px 16px; 355 | position: absolute; 356 | right: 10px; 357 | top: 50%; 358 | transform: translateY(-50%); 359 | } 360 | 361 | .close-sidebar:hover { 362 | color: #4299e1; 363 | background: none; 364 | } 365 | 366 | /* Responsive styles */ 367 | @media (max-width: 768px) { 368 | .menu-toggle { 369 | display: block !important; 370 | z-index: 20; 371 | } 372 | 373 | .chat-header { 374 | position: fixed; 375 | z-index: 10; 376 | background: white; 377 | width: 100%; 378 | } 379 | 380 | .chat-header h2 { 381 | display: flex; 382 | align-items: center; 383 | gap: 10px; 384 | } 385 | 386 | .close-sidebar { 387 | display: block; 388 | } 389 | 390 | .sidebar { 391 | position: fixed; 392 | left: -100%; 393 | top: 0; 394 | bottom: 0; 395 | width: 100%; 396 | z-index: 15; 397 | transition: left 0.3s ease; 398 | background: #f3f4f6; 399 | } 400 | 401 | .input-area { 402 | width: 100%; 403 | margin-left: 0; 404 | } 405 | 406 | .sidebar.show { 407 | left: 0; 408 | } 409 | 410 | .user-section { 411 | position: sticky; 412 | } 413 | 414 | .current-user { 415 | padding-right: 48px; 416 | } 417 | 418 | .chat-area { 419 | position: relative; 420 | z-index: 5; 421 | } 422 | } 423 | 424 | 425 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /web/static/chat.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Andrei Sambra 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | let ws = null; 18 | let currentUsername = null; 19 | let selectedRecipient = null; 20 | let conversations = {}; 21 | let unreadMessages = new Set(); 22 | let currentOnlineUsers = []; 23 | 24 | function getWebSocketUrl(path) { 25 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 26 | const host = window.location.host; 27 | return `${protocol}//${host}${path}`; 28 | } 29 | 30 | window.login = function() { 31 | const username = document.getElementById('usernameInput').value.trim(); 32 | 33 | if (!username) { 34 | alert('Please enter a username'); 35 | return; 36 | } 37 | 38 | currentUsername = username; 39 | 40 | ws = new WebSocket(getWebSocketUrl(`/ws?username=${encodeURIComponent(username)}`)); 41 | 42 | ws.onmessage = function(event) { 43 | const data = JSON.parse(event.data); 44 | 45 | if (Array.isArray(data)) { 46 | updateOnlineUsers(data); 47 | return; 48 | } 49 | 50 | if (data.type === 'logout') { 51 | if (selectedRecipient === data.username) { 52 | selectedRecipient = null; 53 | document.getElementById('currentRecipient').textContent = ''; 54 | document.getElementById('messageInput').disabled = true; 55 | document.getElementById('sendButton').disabled = true; 56 | } 57 | unreadMessages.delete(data.username); 58 | return; 59 | } 60 | 61 | handleIncomingMessage(data); 62 | }; 63 | 64 | ws.onopen = function() { 65 | document.title = `${username}`; 66 | document.getElementById('currentUserDisplay').textContent = username; 67 | document.getElementById('login').style.display = 'none'; 68 | document.getElementById('chat').style.display = 'flex'; 69 | }; 70 | 71 | ws.onclose = function(event) { 72 | if (!event.wasClean) { 73 | const usernameInput = document.getElementById('usernameInput'); 74 | usernameInput.classList.add('error'); 75 | usernameInput.value = ''; 76 | usernameInput.placeholder = 'Username already taken - try another'; 77 | setTimeout(() => { 78 | usernameInput.classList.remove('error'); 79 | usernameInput.placeholder = 'Enter your username'; 80 | }, 3000); 81 | } 82 | resetUI(); 83 | }; 84 | 85 | ws.onerror = function(error) { 86 | console.error('WebSocket error:', error); 87 | }; 88 | } 89 | 90 | function logout() { 91 | if (ws && ws.readyState === WebSocket.OPEN) { 92 | // Send logout message to server 93 | ws.send(JSON.stringify({ 94 | type: 'logout', 95 | username: currentUsername 96 | })); 97 | ws.close(); 98 | } 99 | resetUI(); 100 | } 101 | 102 | function getConversationId(otherUser) { 103 | const users = [currentUsername, otherUser].sort(); 104 | return users.join('-'); 105 | } 106 | 107 | function getOrCreateConversation(conversationId) { 108 | if (!conversations[conversationId]) { 109 | const conversationsDiv = document.getElementById('conversations'); 110 | const newConversation = document.createElement('div'); 111 | newConversation.className = 'conversation'; 112 | newConversation.id = `conversation-${conversationId}`; 113 | conversationsDiv.appendChild(newConversation); 114 | conversations[conversationId] = newConversation; 115 | } 116 | return conversations[conversationId]; 117 | } 118 | 119 | function handleIncomingMessage(data) { 120 | const conversationId = getConversationId(data.from); 121 | let conversation = getOrCreateConversation(conversationId); 122 | 123 | const messageDiv = document.createElement('div'); 124 | messageDiv.className = 'message'; 125 | messageDiv.innerHTML = ` 126 |
127 | ${data.from.charAt(0).toUpperCase()} 128 |
129 |
130 |
131 | ${escapeHtml(data.from)} 132 | ${formatTime(new Date())} 133 |
134 |
${escapeHtml(data.content)}
135 |
136 | `; 137 | conversation.appendChild(messageDiv); 138 | conversation.scrollTop = conversation.scrollHeight; 139 | 140 | if (selectedRecipient !== data.from) { 141 | unreadMessages.add(data.from); 142 | if (!document.hasFocus()) { 143 | document.title = `(New) Chat - ${currentUsername}`; 144 | } 145 | updateUsersList(currentOnlineUsers); 146 | } 147 | } 148 | 149 | function updateOnlineUsers(users) { 150 | currentOnlineUsers = users; 151 | const usersList = document.getElementById('usersList'); 152 | usersList.innerHTML = ''; 153 | 154 | const filteredUsers = users.filter(user => user.username !== currentUsername); 155 | 156 | filteredUsers.forEach(user => { 157 | const userDiv = document.createElement('div'); 158 | userDiv.className = 'user-item'; 159 | if (user.username === selectedRecipient) { 160 | userDiv.className += ' active'; 161 | } 162 | if (unreadMessages.has(user.username)) { 163 | userDiv.className += ' has-unread'; 164 | } 165 | userDiv.textContent = user.username; 166 | userDiv.onclick = () => selectRecipient(user.username); 167 | usersList.appendChild(userDiv); 168 | }); 169 | } 170 | 171 | function updateUsersList(users) { 172 | const usersList = document.getElementById('usersList'); 173 | usersList.innerHTML = ''; 174 | 175 | const filteredUsers = users.filter(user => user.username !== currentUsername); 176 | 177 | filteredUsers.forEach(user => { 178 | const userDiv = document.createElement('div'); 179 | userDiv.className = 'user-item'; 180 | if (user.username === selectedRecipient) { 181 | userDiv.className += ' active'; 182 | } 183 | if (unreadMessages.has(user.username)) { 184 | userDiv.className += ' has-unread'; 185 | } 186 | userDiv.textContent = user.username; 187 | userDiv.onclick = () => selectRecipient(user.username); 188 | usersList.appendChild(userDiv); 189 | }); 190 | } 191 | 192 | function selectRecipient(username) { 193 | selectedRecipient = username; 194 | document.getElementById('currentRecipient').textContent = username; 195 | document.getElementById('messageInput').disabled = false; 196 | document.getElementById('sendButton').disabled = false; 197 | 198 | // Show chat content, hide placeholder 199 | document.querySelector('.placeholder-screen').style.display = 'none'; 200 | document.querySelector('.chat-content').style.display = 'flex'; 201 | document.getElementById('chat-with-text').style.display = 'inline'; 202 | 203 | unreadMessages.delete(username); 204 | document.title = `${currentUsername}`; 205 | 206 | Object.values(conversations).forEach(conv => { 207 | conv.style.display = 'none'; 208 | }); 209 | const currentConversation = getOrCreateConversation(getConversationId(username)); 210 | currentConversation.style.display = 'block'; 211 | 212 | const messageInput = document.getElementById('messageInput'); 213 | messageInput.disabled = false; 214 | messageInput.focus(); 215 | 216 | updateUsersList(currentOnlineUsers); 217 | 218 | hideSidebarOnMobile(); 219 | } 220 | 221 | function sendMessage() { 222 | if (!ws || !selectedRecipient) return; 223 | 224 | const messageInput = document.getElementById('messageInput'); 225 | const content = messageInput.value.trim(); 226 | 227 | if (content) { 228 | const message = { 229 | to: selectedRecipient, 230 | content: content 231 | }; 232 | ws.send(JSON.stringify(message)); 233 | 234 | const conversationId = getConversationId(selectedRecipient); 235 | const conversation = getOrCreateConversation(conversationId); 236 | const messageDiv = document.createElement('div'); 237 | messageDiv.className = 'message'; 238 | messageDiv.innerHTML = ` 239 |
240 | ${currentUsername.charAt(0).toUpperCase()} 241 |
242 |
243 |
244 | You 245 | ${formatTime(new Date())} 246 |
247 |
${escapeHtml(content)}
248 |
249 | `; 250 | conversation.appendChild(messageDiv); 251 | conversation.scrollTop = conversation.scrollHeight; 252 | 253 | messageInput.value = ''; 254 | } 255 | } 256 | 257 | function resetUI() { 258 | document.getElementById('login').style.display = 'block'; 259 | document.getElementById('chat').style.display = 'none'; 260 | document.getElementById('conversations').innerHTML = ''; 261 | document.getElementById('usersList').innerHTML = ''; 262 | document.getElementById('usernameInput').value = ''; 263 | document.getElementById('currentRecipient').textContent = ''; 264 | document.getElementById('currentUserDisplay').textContent = ''; 265 | document.querySelector('.placeholder-screen').style.display = 'flex'; 266 | document.querySelector('.chat-content').style.display = 'none'; 267 | document.getElementById('chat-with-text').style.display = 'none'; 268 | document.title = 'Direct Chat'; 269 | currentUsername = null; 270 | selectedRecipient = null; 271 | conversations = {}; 272 | unreadMessages.clear(); 273 | currentOnlineUsers = []; 274 | ws = null; 275 | } 276 | 277 | function toggleSidebar() { 278 | const sidebar = document.querySelector('.sidebar'); 279 | sidebar.classList.toggle('show'); 280 | } 281 | 282 | function hideSidebarOnMobile() { 283 | if (window.innerWidth <= 768) { 284 | const sidebar = document.querySelector('.sidebar'); 285 | sidebar.classList.remove('show'); 286 | } 287 | } 288 | 289 | function getAvatarColor(username) { 290 | // Generate consistent color based on username 291 | let hash = 0; 292 | for (let i = 0; i < username.length; i++) { 293 | hash = username.charCodeAt(i) + ((hash << 5) - hash); 294 | } 295 | const hue = hash % 360; 296 | return `hsl(${hue}, 70%, 45%)`; // Consistent, vibrant colors 297 | } 298 | 299 | function formatTime(date) { 300 | return date.toLocaleTimeString([], { 301 | hour: '2-digit', 302 | minute: '2-digit', 303 | hour12: false 304 | }); 305 | } 306 | 307 | function escapeHtml(unsafe) { 308 | return unsafe 309 | .replace(/&/g, "&") 310 | .replace(//g, ">") 312 | .replace(/"/g, """) 313 | .replace(/'/g, "'"); 314 | } 315 | 316 | window.onfocus = function() { 317 | if (currentUsername) { 318 | document.title = `Chat - ${currentUsername}`; 319 | } 320 | }; 321 | 322 | document.getElementById('usernameInput').addEventListener('keypress', function(e) { 323 | if (e.key === 'Enter') { 324 | login(); 325 | } 326 | }); 327 | 328 | document.getElementById('messageInput').addEventListener('keypress', function(e) { 329 | if (e.key === 'Enter') { 330 | sendMessage(); 331 | } 332 | }); 333 | 334 | document.addEventListener('click', function(e) { 335 | if (window.innerWidth <= 768) { 336 | const sidebar = document.querySelector('.sidebar'); 337 | const menuToggle = document.querySelector('.menu-toggle'); 338 | 339 | if (!sidebar.contains(e.target) && !menuToggle.contains(e.target) && sidebar.classList.contains('show')) { 340 | sidebar.classList.remove('show'); 341 | } 342 | } 343 | }); 344 | 345 | window.addEventListener('resize', function() { 346 | if (window.innerWidth > 768) { 347 | const sidebar = document.querySelector('.sidebar'); 348 | sidebar.classList.remove('show'); 349 | } 350 | }); --------------------------------------------------------------------------------