aboutsummaryrefslogtreecommitdiffstats
path: root/chat
diff options
context:
space:
mode:
authorGravatar Tom van der Lee <tom@vanderlee.io>2026-06-09 22:53:50 +0200
committerGravatar Tom van der Lee <tom@vanderlee.io>2026-06-09 22:53:50 +0200
commita188df3ae359ab770aac85d19d23739286ef6d41 (patch)
tree0cebfd11277f437eea01fa64cda07da56c2cf7ab /chat
downloadwebsocket-chat-a188df3ae359ab770aac85d19d23739286ef6d41.tar.gz
websocket-chat-a188df3ae359ab770aac85d19d23739286ef6d41.tar.bz2
websocket-chat-a188df3ae359ab770aac85d19d23739286ef6d41.zip
Initial commit
Diffstat (limited to 'chat')
-rw-r--r--chat/__init__.py0
-rw-r--r--chat/asgi.py28
-rw-r--r--chat/consumers.py65
-rw-r--r--chat/routing.py7
-rw-r--r--chat/settings.py135
-rw-r--r--chat/templates/chat.html136
-rw-r--r--chat/templates/room.html451
-rw-r--r--chat/urls.py26
-rw-r--r--chat/views.py14
-rw-r--r--chat/wsgi.py16
10 files changed, 878 insertions, 0 deletions
diff --git a/chat/__init__.py b/chat/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/chat/__init__.py
diff --git a/chat/asgi.py b/chat/asgi.py
new file mode 100644
index 0000000..247393e
--- /dev/null
+++ b/chat/asgi.py
@@ -0,0 +1,28 @@
1"""
2ASGI config for chat project.
3
4It exposes the ASGI callable as a module-level variable named ``application``.
5
6For more information on this file, see
7https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
8"""
9
10import os
11
12from channels.auth import AuthMiddlewareStack
13from channels.routing import ProtocolTypeRouter, URLRouter
14from channels.security.websocket import AllowedHostsOriginValidator
15from django.core.asgi import get_asgi_application
16
17from chat.routing import websocket_urlpatterns
18
19os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chat.settings')
20
21application = ProtocolTypeRouter(
22 {
23 "http": get_asgi_application(),
24 "websocket": AllowedHostsOriginValidator(
25 AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
26 ),
27 }
28)
diff --git a/chat/consumers.py b/chat/consumers.py
new file mode 100644
index 0000000..bfba381
--- /dev/null
+++ b/chat/consumers.py
@@ -0,0 +1,65 @@
1import json
2
3from asgiref.sync import async_to_sync
4from channels.generic.websocket import WebsocketConsumer
5
6
7class ChatConsumer(WebsocketConsumer):
8 def connect(self):
9 self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
10 self.room_group_name = f"chat_{self.room_name}"
11 self.username = "Anonymous"
12
13 async_to_sync(self.channel_layer.group_add)(
14 self.room_group_name, self.channel_name
15 )
16 self.accept()
17
18 def disconnect(self, close_code):
19 async_to_sync(self.channel_layer.group_discard)(
20 self.room_group_name, self.channel_name
21 )
22 async_to_sync(self.channel_layer.group_send)(
23 self.room_group_name,
24 {"type": "system.message", "text": f"{self.username} left the chat"},
25 )
26
27 def receive(self, text_data):
28 text_data_json = json.loads(text_data)
29 msg_type = text_data_json.get("type", "message")
30
31 if msg_type == "join":
32 self.username = text_data_json.get("username", "Anonymous")
33 async_to_sync(self.channel_layer.group_send)(
34 self.room_group_name,
35 {"type": "system.message", "text": f"{self.username} joined the chat"},
36 )
37 return
38
39 if msg_type == "rename":
40 old_name = self.username
41 self.username = text_data_json.get("username", self.username)
42 async_to_sync(self.channel_layer.group_send)(
43 self.room_group_name,
44 {"type": "system.message", "text": f"{old_name} is now known as {self.username}"},
45 )
46 return
47
48 message = text_data_json["message"]
49 username = text_data_json.get("username", self.username)
50 async_to_sync(self.channel_layer.group_send)(
51 self.room_group_name,
52 {"type": "chat.message", "message": message, "username": username},
53 )
54
55 def chat_message(self, event):
56 self.send(text_data=json.dumps({
57 "message": event["message"],
58 "username": event["username"],
59 }))
60
61 def system_message(self, event):
62 self.send(text_data=json.dumps({
63 "type": "system",
64 "text": event["text"],
65 }))
diff --git a/chat/routing.py b/chat/routing.py
new file mode 100644
index 0000000..9f08ddf
--- /dev/null
+++ b/chat/routing.py
@@ -0,0 +1,7 @@
1from django.urls import re_path
2
3from . import consumers
4
5websocket_urlpatterns = [
6 re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
7]
diff --git a/chat/settings.py b/chat/settings.py
new file mode 100644
index 0000000..653eea9
--- /dev/null
+++ b/chat/settings.py
@@ -0,0 +1,135 @@
1"""
2Django settings for chat project.
3
4Generated by 'django-admin startproject' using Django 5.0.3.
5
6For more information on this file, see
7https://docs.djangoproject.com/en/5.0/topics/settings/
8
9For the full list of settings and their values, see
10https://docs.djangoproject.com/en/5.0/ref/settings/
11"""
12
13from pathlib import Path
14
15# Build paths inside the project like this: BASE_DIR / 'subdir'.
16BASE_DIR = Path(__file__).resolve().parent.parent
17
18
19# Quick-start development settings - unsuitable for production
20# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
21
22# SECURITY WARNING: keep the secret key used in production secret!
23SECRET_KEY = 'django-insecure-dxn^kf6b8qm@#cqg4+jncop793g7*3mgok&p$40di_cxbm+y$3'
24
25# SECURITY WARNING: don't run with debug turned on in production!
26DEBUG = True
27
28ALLOWED_HOSTS = ['*']
29
30
31# Application definition
32
33INSTALLED_APPS = [
34 'chat',
35 'daphne',
36 'django.contrib.admin',
37 'django.contrib.auth',
38 'django.contrib.contenttypes',
39 'django.contrib.sessions',
40 'django.contrib.messages',
41 'django.contrib.staticfiles',
42]
43
44MIDDLEWARE = [
45 'django.middleware.security.SecurityMiddleware',
46 'django.contrib.sessions.middleware.SessionMiddleware',
47 'django.middleware.common.CommonMiddleware',
48 'django.middleware.csrf.CsrfViewMiddleware',
49 'django.contrib.auth.middleware.AuthenticationMiddleware',
50 'django.contrib.messages.middleware.MessageMiddleware',
51 'django.middleware.clickjacking.XFrameOptionsMiddleware',
52]
53
54ROOT_URLCONF = 'chat.urls'
55
56TEMPLATES = [
57 {
58 'BACKEND': 'django.template.backends.django.DjangoTemplates',
59 'DIRS': [],
60 'APP_DIRS': True,
61 'OPTIONS': {
62 'context_processors': [
63 'django.template.context_processors.debug',
64 'django.template.context_processors.request',
65 'django.contrib.auth.context_processors.auth',
66 'django.contrib.messages.context_processors.messages',
67 ],
68 },
69 },
70]
71
72WSGI_APPLICATION = 'chat.wsgi.application'
73ASGI_APPLICATION = 'chat.asgi.application'
74
75
76# Database
77# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
78
79DATABASES = {
80 'default': {
81 'ENGINE': 'django.db.backends.sqlite3',
82 'NAME': BASE_DIR / 'db.sqlite3',
83 }
84}
85
86
87# Password validation
88# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
89
90AUTH_PASSWORD_VALIDATORS = [
91 {
92 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
93 },
94 {
95 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
96 },
97 {
98 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
99 },
100 {
101 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
102 },
103]
104
105
106# Internationalization
107# https://docs.djangoproject.com/en/5.0/topics/i18n/
108
109LANGUAGE_CODE = 'en-us'
110
111TIME_ZONE = 'UTC'
112
113USE_I18N = True
114
115USE_TZ = True
116
117
118# Static files (CSS, JavaScript, Images)
119# https://docs.djangoproject.com/en/5.0/howto/static-files/
120
121STATIC_URL = 'static/'
122
123# Default primary key field type
124# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
125
126DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
127
128CHANNEL_LAYERS = {
129 "default": {
130 "BACKEND": "channels_redis.core.RedisChannelLayer",
131 "CONFIG": {
132 "hosts": [("127.0.0.1", 6379)],
133 },
134 },
135}
diff --git a/chat/templates/chat.html b/chat/templates/chat.html
new file mode 100644
index 0000000..8542368
--- /dev/null
+++ b/chat/templates/chat.html
@@ -0,0 +1,136 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="utf-8"/>
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 <meta name="theme-color" content="#c9b8ff">
7 <title>dreamchat</title>
8 <style>
9 :root {
10 --grad-start: #c9b8ff;
11 --grad-end: #f9b8e8;
12 --accent: #8b6cef;
13 --surface: #ffffff;
14 --text-main: #2d2040;
15 --text-muted: #8c7faa;
16 --radius-card: 24px;
17 --shadow: 0 8px 32px rgba(139, 108, 239, 0.18);
18 --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
19 }
20
21 * { box-sizing: border-box; margin: 0; padding: 0; }
22
23 body {
24 min-height: 100dvh;
25 background: linear-gradient(135deg, var(--grad-start), var(--grad-end));
26 display: flex;
27 align-items: center;
28 justify-content: center;
29 font-family: var(--font);
30 padding: 24px;
31 }
32
33 .card {
34 background: var(--surface);
35 border-radius: var(--radius-card);
36 box-shadow: var(--shadow);
37 padding: 40px 32px;
38 width: 100%;
39 max-width: 400px;
40 text-align: center;
41 }
42
43 .logo {
44 font-size: 3rem;
45 margin-bottom: 12px;
46 line-height: 1;
47 }
48
49 .title {
50 font-size: 2rem;
51 font-weight: 700;
52 background: linear-gradient(135deg, var(--accent), #e06aba);
53 -webkit-background-clip: text;
54 -webkit-text-fill-color: transparent;
55 background-clip: text;
56 margin-bottom: 8px;
57 }
58
59 .subtitle {
60 color: var(--text-muted);
61 margin-bottom: 28px;
62 font-size: 0.95rem;
63 }
64
65 .input-row {
66 display: flex;
67 gap: 10px;
68 }
69
70 .input-row input {
71 flex: 1;
72 min-width: 0;
73 border: 2px solid #e8e0ff;
74 border-radius: 50px;
75 padding: 12px 18px;
76 font-size: 1rem;
77 font-family: var(--font);
78 color: var(--text-main);
79 outline: none;
80 transition: border-color 0.2s;
81 }
82
83 .input-row input:focus {
84 border-color: var(--accent);
85 }
86
87 .input-row input::placeholder {
88 color: var(--text-muted);
89 }
90
91 .input-row button {
92 background: linear-gradient(135deg, var(--accent), #b06add);
93 color: #fff;
94 border: none;
95 border-radius: 50px;
96 padding: 12px 22px;
97 font-size: 1rem;
98 font-weight: 600;
99 font-family: var(--font);
100 cursor: pointer;
101 white-space: nowrap;
102 transition: opacity 0.15s, transform 0.1s;
103 }
104
105 .input-row button:hover { opacity: 0.9; }
106 .input-row button:active { transform: scale(0.96); }
107 </style>
108</head>
109<body>
110 <div class="card">
111 <div class="logo">💬</div>
112 <h1 class="title">dreamchat</h1>
113 <p class="subtitle">Where do you want to go?</p>
114 <div class="input-row">
115 <input id="room-name-input" type="text" placeholder="Room name"
116 aria-label="Room name"
117 autocomplete="off" autocapitalize="off" spellcheck="false">
118 <button id="room-name-submit">Enter</button>
119 </div>
120 </div>
121
122 <script>
123 document.querySelector('#room-name-input').focus();
124 document.querySelector('#room-name-input').onkeyup = function(event) {
125 if (event.key === 'Enter') {
126 document.querySelector('#room-name-submit').click();
127 }
128 };
129
130 document.querySelector('#room-name-submit').onclick = function() {
131 const roomName = document.querySelector('#room-name-input').value;
132 window.location.pathname = '/' + roomName + '/';
133 };
134 </script>
135</body>
136</html>
diff --git a/chat/templates/room.html b/chat/templates/room.html
new file mode 100644
index 0000000..de76f67
--- /dev/null
+++ b/chat/templates/room.html
@@ -0,0 +1,451 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="utf-8"/>
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 <meta name="theme-color" content="#c9b8ff">
7 <title>{{ room_name }} — dreamchat</title>
8 <style>
9 :root {
10 --grad-start: #c9b8ff;
11 --grad-end: #f9b8e8;
12 --accent: #8b6cef;
13 --bubble-in-bg: #f0ebff;
14 --bubble-in-text: #2d2040;
15 --surface: #ffffff;
16 --text-main: #2d2040;
17 --text-muted: #8c7faa;
18 --radius-bubble: 18px;
19 --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
20 }
21
22 * { box-sizing: border-box; margin: 0; padding: 0; }
23
24 body {
25 height: 100dvh;
26 display: flex;
27 flex-direction: column;
28 background: linear-gradient(160deg, #ede7ff 0%, #fce4f6 100%);
29 font-family: var(--font);
30 overflow: hidden;
31 }
32
33 /* ---- Header ---- */
34 .room-header {
35 display: flex;
36 align-items: center;
37 gap: 12px;
38 padding: 10px 16px;
39 padding-top: calc(10px + env(safe-area-inset-top));
40 background: rgba(255, 255, 255, 0.75);
41 backdrop-filter: blur(12px);
42 -webkit-backdrop-filter: blur(12px);
43 border-bottom: 1px solid rgba(139, 108, 239, 0.12);
44 flex-shrink: 0;
45 }
46
47 .back-btn {
48 display: flex;
49 align-items: center;
50 justify-content: center;
51 width: 32px;
52 height: 32px;
53 border-radius: 50%;
54 color: var(--accent);
55 text-decoration: none;
56 font-size: 1.2rem;
57 font-weight: 600;
58 transition: background 0.15s;
59 flex-shrink: 0;
60 }
61
62 .back-btn:hover { background: #f0ebff; }
63
64 .header-center {
65 flex: 1;
66 min-width: 0;
67 display: flex;
68 flex-direction: column;
69 gap: 2px;
70 }
71
72 .room-title {
73 font-size: 1.05rem;
74 font-weight: 700;
75 background: linear-gradient(135deg, var(--accent), #e06aba);
76 -webkit-background-clip: text;
77 -webkit-text-fill-color: transparent;
78 background-clip: text;
79 white-space: nowrap;
80 overflow: hidden;
81 text-overflow: ellipsis;
82 }
83
84 .username-row { display: flex; align-items: center; }
85
86 .username-chip {
87 display: flex;
88 align-items: center;
89 gap: 4px;
90 background: none;
91 border: none;
92 padding: 0;
93 font-size: 0.78rem;
94 font-family: var(--font);
95 color: var(--text-muted);
96 cursor: pointer;
97 transition: color 0.15s;
98 }
99
100 .username-chip:hover { color: var(--accent); }
101 .edit-icon { font-size: 0.7rem; }
102
103 .username-edit {
104 font-size: 0.78rem;
105 font-family: var(--font);
106 color: var(--text-main);
107 border: none;
108 border-bottom: 1.5px solid var(--accent);
109 outline: none;
110 background: transparent;
111 width: 130px;
112 padding: 1px 2px;
113 }
114
115 .disconnect-btn {
116 display: flex;
117 align-items: center;
118 justify-content: center;
119 width: 32px;
120 height: 32px;
121 background: none;
122 border: 1.5px solid #d0c5f0;
123 color: var(--text-muted);
124 border-radius: 50%;
125 font-size: 0.85rem;
126 cursor: pointer;
127 flex-shrink: 0;
128 transition: background 0.15s, border-color 0.15s;
129 }
130
131 .disconnect-btn:hover { background: #f0ebff; border-color: var(--accent); }
132
133 /* ---- Message area ---- */
134 .chat-log {
135 flex: 1;
136 overflow-y: auto;
137 -webkit-overflow-scrolling: touch;
138 padding: 16px 12px;
139 display: flex;
140 flex-direction: column;
141 gap: 6px;
142 }
143
144 /* ---- Bubbles ---- */
145 .bubble-row {
146 display: flex;
147 width: 100%;
148 }
149
150 .bubble-row.sent { justify-content: flex-end; }
151 .bubble-row.recv { justify-content: flex-start; }
152
153 .bubble-wrapper {
154 display: flex;
155 flex-direction: column;
156 max-width: 72%;
157 gap: 3px;
158 }
159
160 .bubble-row.sent .bubble-wrapper { align-items: flex-end; }
161 .bubble-row.recv .bubble-wrapper { align-items: flex-start; }
162
163 .bubble {
164 padding: 10px 15px;
165 border-radius: var(--radius-bubble);
166 font-size: 0.97rem;
167 line-height: 1.45;
168 word-wrap: break-word;
169 animation: pop-in 0.15s ease-out;
170 }
171
172 .sent .bubble {
173 background: linear-gradient(135deg, var(--accent), #b06add);
174 color: #ffffff;
175 border-bottom-right-radius: 4px;
176 }
177
178 .recv .bubble {
179 background: var(--bubble-in-bg);
180 color: var(--bubble-in-text);
181 border-bottom-left-radius: 4px;
182 box-shadow: 0 2px 8px rgba(139, 108, 239, 0.1);
183 }
184
185 .bubble-sender {
186 font-size: 0.75rem;
187 font-weight: 600;
188 color: var(--accent);
189 padding: 0 4px;
190 }
191
192 .bubble-time {
193 font-size: 0.7rem;
194 color: var(--text-muted);
195 padding: 0 4px;
196 }
197
198 @keyframes pop-in {
199 from { opacity: 0; transform: scale(0.9) translateY(4px); }
200 to { opacity: 1; transform: scale(1) translateY(0); }
201 }
202
203 /* ---- Input bar ---- */
204 .input-bar {
205 display: flex;
206 align-items: center;
207 gap: 10px;
208 padding: 12px 16px;
209 padding-bottom: calc(12px + env(safe-area-inset-bottom));
210 background: rgba(255, 255, 255, 0.85);
211 backdrop-filter: blur(12px);
212 -webkit-backdrop-filter: blur(12px);
213 border-top: 1px solid rgba(139, 108, 239, 0.12);
214 flex-shrink: 0;
215 }
216
217 .input-bar input {
218 flex: 1;
219 min-width: 0;
220 border: 2px solid #e8e0ff;
221 border-radius: 50px;
222 padding: 11px 18px;
223 font-size: 1rem;
224 font-family: var(--font);
225 color: var(--text-main);
226 background: #fff;
227 outline: none;
228 transition: border-color 0.2s;
229 }
230
231 .input-bar input:focus { border-color: var(--accent); }
232 .input-bar input::placeholder { color: var(--text-muted); }
233
234 .send-btn {
235 display: flex;
236 align-items: center;
237 justify-content: center;
238 width: 44px;
239 height: 44px;
240 background: linear-gradient(135deg, var(--accent), #b06add);
241 color: #fff;
242 border: none;
243 border-radius: 50%;
244 font-size: 1rem;
245 cursor: pointer;
246 flex-shrink: 0;
247 transition: opacity 0.15s, transform 0.1s;
248 }
249
250 .send-btn:hover { opacity: 0.9; }
251 .send-btn:active { transform: scale(0.93); }
252
253 /* ---- System messages ---- */
254 .system-msg {
255 display: flex;
256 align-items: center;
257 gap: 10px;
258 font-size: 0.72rem;
259 color: var(--text-muted);
260 padding: 4px 0;
261 animation: pop-in 0.2s ease-out;
262 }
263
264 .system-msg::before,
265 .system-msg::after {
266 content: '';
267 flex: 1;
268 height: 1px;
269 background: rgba(139, 108, 239, 0.2);
270 }
271 </style>
272</head>
273<body>
274 <header class="room-header">
275 <a href="/" class="back-btn" aria-label="Back to rooms">←</a>
276 <div class="header-center">
277 <span class="room-title">{{ room_name }}</span>
278 <div class="username-row">
279 <button id="username-display" class="username-chip" aria-label="Edit username">
280 <span id="username-text"></span>
281 <span class="edit-icon">✏️</span>
282 </button>
283 <input id="username-input" class="username-edit"
284 aria-label="Your username" hidden
285 maxlength="30" autocomplete="off" spellcheck="false">
286 </div>
287 </div>
288 <button id="chat-message-disconnect" class="disconnect-btn" hidden aria-label="Disconnect">✕</button>
289 </header>
290
291 <main id="chat-log" class="chat-log" aria-live="polite" aria-label="Chat messages"></main>
292
293 <footer class="input-bar">
294 <input id="chat-message-input" type="text" placeholder="Message…"
295 aria-label="Message"
296 autocomplete="off" autocapitalize="sentences">
297 <button id="chat-message-submit" class="send-btn" aria-label="Send">➤</button>
298 </footer>
299
300 {{ room_name|json_script:"room-name" }}
301 <script>
302 const ADJECTIVES = ['Dreamy','Cosmic','Fuzzy','Glowing','Misty','Silky','Starry','Velvet','Cozy','Dewy','Wispy','Breezy'];
303 const ANIMALS = ['Fox','Moth','Owl','Bear','Deer','Lark','Lynx','Mink','Swan','Wolf','Wren','Panda'];
304
305 function generateUsername() {
306 const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
307 const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
308 return adj + animal;
309 }
310
311 let username = localStorage.getItem('dreamchat_username') || generateUsername();
312 localStorage.setItem('dreamchat_username', username);
313 document.getElementById('username-text').textContent = username;
314
315 document.getElementById('username-display').onclick = function() {
316 const input = document.getElementById('username-input');
317 input.value = username;
318 input.removeAttribute('hidden');
319 this.setAttribute('hidden', '');
320 input.focus();
321 input.select();
322 };
323
324 function commitUsername() {
325 const input = document.getElementById('username-input');
326 const newName = input.value.trim();
327 if (newName && newName !== username) {
328 username = newName;
329 localStorage.setItem('dreamchat_username', username);
330 document.getElementById('username-text').textContent = username;
331 if (chatSocket.readyState === WebSocket.OPEN) {
332 chatSocket.send(JSON.stringify({ type: 'rename', username }));
333 }
334 }
335 input.setAttribute('hidden', '');
336 document.getElementById('username-display').removeAttribute('hidden');
337 }
338
339 document.getElementById('username-input').onblur = commitUsername;
340 document.getElementById('username-input').onkeyup = function(event) {
341 if (event.key === 'Enter') { this.blur(); }
342 if (event.key === 'Escape') {
343 this.setAttribute('hidden', '');
344 document.getElementById('username-display').removeAttribute('hidden');
345 }
346 };
347
348 function formatTime(date) {
349 return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
350 }
351
352 const roomName = JSON.parse(document.getElementById('room-name').textContent);
353 const chatLog = document.getElementById('chat-log');
354 const pendingEchoes = new Set();
355
356 function appendSystemMessage(text) {
357 const el = document.createElement('div');
358 el.className = 'system-msg';
359 el.textContent = text;
360 chatLog.appendChild(el);
361 chatLog.scrollTop = chatLog.scrollHeight;
362 }
363
364 function appendBubble(text, direction, sender, time) {
365 const row = document.createElement('div');
366 row.className = 'bubble-row ' + direction;
367
368 const wrapper = document.createElement('div');
369 wrapper.className = 'bubble-wrapper';
370
371 if (direction === 'recv') {
372 const nameEl = document.createElement('div');
373 nameEl.className = 'bubble-sender';
374 nameEl.textContent = sender;
375 wrapper.appendChild(nameEl);
376 }
377
378 const bubble = document.createElement('div');
379 bubble.className = 'bubble';
380 bubble.textContent = text;
381 wrapper.appendChild(bubble);
382
383 const timeEl = document.createElement('div');
384 timeEl.className = 'bubble-time';
385 timeEl.textContent = time;
386 wrapper.appendChild(timeEl);
387
388 row.appendChild(wrapper);
389 chatLog.appendChild(row);
390 chatLog.scrollTop = chatLog.scrollHeight;
391 }
392
393 const chatSocket = new WebSocket(
394 'ws://'
395 + window.location.host
396 + '/ws/chat/'
397 + roomName
398 + '/'
399 + '?token=123'
400 );
401
402 chatSocket.onopen = function() {
403 document.querySelector('#chat-message-disconnect').removeAttribute('hidden');
404 chatSocket.send(JSON.stringify({ type: 'join', username }));
405 };
406
407 chatSocket.onmessage = function(e) {
408 const data = JSON.parse(e.data);
409
410 if (data.type === 'system') {
411 appendSystemMessage(data.text);
412 return;
413 }
414
415 const key = data.username + '|' + data.message;
416 if (pendingEchoes.has(key)) {
417 pendingEchoes.delete(key);
418 return;
419 }
420 appendBubble(data.message, 'recv', data.username, formatTime(new Date()));
421 };
422
423 chatSocket.onclose = function() {
424 console.error('Chat socket closed unexpectedly');
425 document.querySelector('#chat-message-disconnect').setAttribute('hidden', '');
426 };
427
428 document.querySelector('#chat-message-input').focus();
429 document.querySelector('#chat-message-input').onkeyup = function(event) {
430 if (event.key === 'Enter') {
431 document.querySelector('#chat-message-submit').click();
432 }
433 };
434
435 document.querySelector('#chat-message-submit').onclick = function() {
436 const messageInputDom = document.querySelector('#chat-message-input');
437 const message = messageInputDom.value.trim();
438 if (!message) return;
439 const time = formatTime(new Date());
440 pendingEchoes.add(username + '|' + message);
441 appendBubble(message, 'sent', username, time);
442 chatSocket.send(JSON.stringify({ message, username }));
443 messageInputDom.value = '';
444 };
445
446 document.querySelector('#chat-message-disconnect').onclick = function() {
447 chatSocket.close();
448 };
449 </script>
450</body>
451</html>
diff --git a/chat/urls.py b/chat/urls.py
new file mode 100644
index 0000000..b96b107
--- /dev/null
+++ b/chat/urls.py
@@ -0,0 +1,26 @@
1"""
2URL configuration for chat project.
3
4The `urlpatterns` list routes URLs to views. For more information please see:
5 https://docs.djangoproject.com/en/5.0/topics/http/urls/
6Examples:
7Function views
8 1. Add an import: from my_app import views
9 2. Add a URL to urlpatterns: path('', views.home, name='home')
10Class-based views
11 1. Add an import: from other_app.views import Home
12 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13Including another URLconf
14 1. Import the include() function: from django.urls import include, path
15 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16"""
17from django.contrib import admin
18from django.urls import path
19
20from chat.views import Chat, Room
21
22urlpatterns = [
23 path('admin/', admin.site.urls),
24 path('<str:room>/', Room.as_view()),
25 path('', Chat.as_view()),
26]
diff --git a/chat/views.py b/chat/views.py
new file mode 100644
index 0000000..512837e
--- /dev/null
+++ b/chat/views.py
@@ -0,0 +1,14 @@
1from django.views.generic import TemplateView
2
3
4class Chat(TemplateView):
5 template_name = 'chat.html'
6
7class Room(TemplateView):
8 template_name = 'room.html'
9
10 def get_context_data(self, **kwargs):
11 return {
12 **super().get_context_data(**kwargs),
13 'room_name': kwargs.get('room'),
14 }
diff --git a/chat/wsgi.py b/chat/wsgi.py
new file mode 100644
index 0000000..cdfbb23
--- /dev/null
+++ b/chat/wsgi.py
@@ -0,0 +1,16 @@
1"""
2WSGI config for chat project.
3
4It exposes the WSGI callable as a module-level variable named ``application``.
5
6For more information on this file, see
7https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
8"""
9
10import os
11
12from django.core.wsgi import get_wsgi_application
13
14os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'chat.settings')
15
16application = get_wsgi_application()