├── .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 |
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 |
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 |
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 | });
--------------------------------------------------------------------------------