refactor: enhance user connection management and improve roster handling
Kim committed
Oct 22, 2025 at 13:51 UTC
1cef95e899976ec5112ca8c66ad4962d9a9f4e75
1 file changed
+154
-38
cmd/example_chat/view.go
+154
-38
@@ -5,6 +5,8 @@ import (
5
"html/template"
6
"net"
7
"net/http"
8
+ "sort"
9
+ "strconv"
10
"sync"
11
"time"
12
@@ -15,34 +17,47 @@ import (
17
18
// simple in-memory chat hub
19
type hub struct {
18
- mu sync.RWMutex
19
- messages []message
20
- conns map[*websocket.Conn]struct{}
21
- names map[*websocket.Conn]string
22
- wg sync.WaitGroup
23
- store *messageStore
20
+ mu sync.RWMutex
21
+ messages []message
22
+ conns map[*websocket.Conn]struct{}
23
+ connUID map[*websocket.Conn]string
24
+ userConns map[string]map[*websocket.Conn]struct{}
25
+ userName map[string]string
26
+ wg sync.WaitGroup
27
+ store *messageStore
28
}
29
30
type message struct {
31
TS time.Time `json:"ts"`
32
User string `json:"user"`
33
Text string `json:"text"`
30
- Event string `json:"event,omitempty"` // "joined" | "left"
34
+ Event string `json:"event,omitempty"` // "joined" | "left" | "roster"
35
+ UID string `json:"uid,omitempty"`
36
+ Users []string `json:"users,omitempty"`
37
}
38
39
func newHub() *hub {
34
- return &hub{conns: map[*websocket.Conn]struct{}{}, names: map[*websocket.Conn]string{}, messages: make([]message, 0, 64)}
40
+ return &hub{
41
+ conns: map[*websocket.Conn]struct{}{},
42
+ connUID: map[*websocket.Conn]string{},
43
+ userConns: map[string]map[*websocket.Conn]struct{}{},
44
+ userName: map[string]string{},
45
+ messages: make([]message, 0, 64),
46
+ }
47
}
48
49
func (h *hub) broadcast(m message) {
50
h.mu.Lock()
39
- h.messages = append(h.messages, m)
51
+ // Do not persist/retain roster messages in backlog; they are ephemeral UI state
52
+ if m.Event != "roster" {
53
+ h.messages = append(h.messages, m)
54
+ }
55
conns := make([]*websocket.Conn, 0, len(h.conns))
56
for c := range h.conns {
57
conns = append(conns, c)
58
}
59
h.mu.Unlock()
45
- if h.store != nil {
60
+ if h.store != nil && m.Event != "roster" {
61
if err := h.store.Append(m); err != nil {
62
log.Debug().Err(err).Msg("persist message")
63
}
@@ -54,6 +69,26 @@ func (h *hub) broadcast(m message) {
69
}
70
}
71
72
+// broadcastRoster sends the current list of connected user names to all clients.
73
+func (h *hub) broadcastRoster() {
74
+ // Build roster snapshot
75
+ h.mu.RLock()
76
+ users := make([]string, 0, len(h.userName))
77
+ for uid, name := range h.userName {
78
+ if set, ok := h.userConns[uid]; !ok || len(set) == 0 {
79
+ continue
80
+ }
81
+ if name == "" {
82
+ name = "anon"
83
+ }
84
+ users = append(users, name)
85
+ }
86
+ h.mu.RUnlock()
87
+ // Sort for stable UI order
88
+ sort.Strings(users)
89
+ h.broadcast(message{TS: time.Now().UTC(), Event: "roster", Users: users})
90
+}
91
+
92
// attachStore connects a persistent store to the hub.
93
func (h *hub) attachStore(s *messageStore) {
94
h.mu.Lock()
@@ -108,15 +143,31 @@ func handleWS(w http.ResponseWriter, r *http.Request, h *hub) {
143
go func() {
144
defer func() {
145
var leftUser string
146
+ var uid string
147
+ var lastConn bool
148
h.mu.Lock()
112
- if name, ok := h.names[conn]; ok && name != "" {
113
- leftUser = name
149
+ uid = h.connUID[conn]
150
+ if uid != "" {
151
+ if set, ok := h.userConns[uid]; ok {
152
+ delete(set, conn)
153
+ if len(set) == 0 {
154
+ lastConn = true
155
+ delete(h.userConns, uid)
156
+ } else {
157
+ h.userConns[uid] = set
158
+ }
159
+ }
160
+ leftUser = h.userName[uid]
161
+ if lastConn {
162
+ delete(h.userName, uid)
163
+ }
164
+ delete(h.connUID, conn)
165
}
115
- delete(h.names, conn)
166
delete(h.conns, conn)
167
h.mu.Unlock()
118
- if leftUser != "" {
168
+ if leftUser != "" && lastConn {
169
h.broadcast(message{TS: time.Now().UTC(), User: leftUser, Event: "left"})
170
+ h.broadcastRoster()
171
}
172
_ = conn.Close(websocket.StatusNormalClosure, "")
173
cancelConn()
@@ -126,6 +177,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, h *hub) {
177
var req struct {
178
User string `json:"user"`
179
Text string `json:"text"`
180
+ UID string `json:"uid"`
181
}
182
if err := wsjson.Read(connCtx, conn, &req); err != nil {
183
return
@@ -133,16 +185,40 @@ func handleWS(w http.ResponseWriter, r *http.Request, h *hub) {
185
if req.User == "" {
186
req.User = "anon"
187
}
136
- // first frame per connection: remember name and announce join
188
+ if req.UID == "" {
189
+ // Fallback to a per-connection unique id if client didn't provide one
190
+ req.UID = strconv.FormatInt(time.Now().UnixNano(), 10)
191
+ }
192
+ // map connection to uid and maintain per-user state
193
var announce bool
194
+ var renamed bool
195
+ var prevName string
196
h.mu.Lock()
139
- if _, ok := h.names[conn]; !ok {
140
- h.names[conn] = req.User
141
- announce = true
197
+ if _, ok := h.connUID[conn]; !ok {
198
+ h.connUID[conn] = req.UID
199
+ if _, ok := h.userConns[req.UID]; !ok {
200
+ h.userConns[req.UID] = map[*websocket.Conn]struct{}{}
201
+ }
202
+ if len(h.userConns[req.UID]) == 0 {
203
+ announce = true
204
+ }
205
+ h.userConns[req.UID][conn] = struct{}{}
206
+ }
207
+ if cur, ok := h.userName[req.UID]; !ok {
208
+ h.userName[req.UID] = req.User
209
+ } else if cur != req.User {
210
+ prevName = cur
211
+ h.userName[req.UID] = req.User
212
+ renamed = true
213
}
214
h.mu.Unlock()
215
if announce {
216
h.broadcast(message{TS: time.Now().UTC(), User: req.User, Event: "joined"})
217
+ h.broadcastRoster()
218
+ } else if renamed {
219
+ // Announce rename as an event line in chat
220
+ h.broadcast(message{TS: time.Now().UTC(), User: prevName, Text: req.User, Event: "rename"})
221
+ h.broadcastRoster()
222
}
223
if req.Text == "" {
224
continue
@@ -197,8 +273,9 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
273
body { margin:0; padding:24px; background:var(--bg); color:var(--fg); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial }
274
.wrap { max-width: 920px; margin: 0 auto }
275
h1 { margin:0 0 12px 0; font-weight:700 }
200
- .term { border:1px solid var(--border); border-radius:10px; background:var(--panel); overflow:hidden }
276
+ .term { border:1px solid var(--border); border-radius:10px; background:var(--panel); overflow:hidden; position: relative }
277
.termbar { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid var(--border); font-family: 'D2Coding', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:14px }
278
+ .term-actions { display:flex; align-items:center; gap:8px }
279
.dots { display:flex; gap:6px }
280
.dot { width:10px; height:10px; border-radius:50%; }
281
.dot.red{ background:#ef4444 }
@@ -218,6 +295,14 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
295
#cmd { flex:1; background:transparent; border:none; outline:none; color:var(--fg); font-family: inherit; font-size:14px; caret-color: var(--cursor) }
296
small{ color:var(--muted); display:block; margin-top:10px }
297
298
+ /* Scrollbar styling for log and users list */
299
+ .screen { scrollbar-width: thin; scrollbar-color: #374151 #0d1117; }
300
+ .screen::-webkit-scrollbar { width: 10px }
301
+ .screen::-webkit-scrollbar-track { background: #0d1117 }
302
+ .screen::-webkit-scrollbar-thumb { background: #374151; border-radius: 8px; border: 2px solid #111827 }
303
+ .screen::-webkit-scrollbar-thumb:hover { background: #4b5563 }
304
+ .userspill { display:inline-block; border:1px solid var(--border); padding:2px 10px; border-radius:999px; color:var(--fg); font-size:12px; opacity:.9 }
305
+
306
/* Mobile responsiveness */
307
@media (max-width: 640px) {
308
body { padding: 12px; }
@@ -238,17 +323,17 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
323
<div class="term">
324
<div class="termbar">
325
<div class="dots"><span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span></div>
241
- <div style="opacity:.9">relaychat@relaydns</div>
242
- <div class="nick">
243
- <label for="user" style="color:var(--muted)">nick</label>
244
- <input id="user" type="text" placeholder="anon" />
245
- <button id="roll" title="randomize nickname">🎲</button>
246
- </div>
326
+ <div class="term-actions"><span class="userspill">Users <span id="users-count">0</span></span></div>
327
</div>
328
<div id="log" class="screen"></div>
329
<div class="promptline">
330
<span id="prompt"></span>
331
<input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="type a message and press Enter" />
332
+ <div class="nick" style="margin-left:auto">
333
+ <label for="user" style="color:var(--muted)">nick</label>
334
+ <input id="user" type="text" placeholder="anon" />
335
+ <button id="roll" title="randomize nickname">🎲</button>
336
+ </div>
337
</div>
338
</div>
339
<small>Tip: Enter to send • Nickname persists locally</small>
@@ -259,31 +344,54 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
344
const cmd = document.getElementById('cmd');
345
const roll = document.getElementById('roll');
346
const promptEl = document.getElementById('prompt');
347
+ const usersCount = document.getElementById('users-count');
348
349
function setPrompt(){
350
const nick = (user.value || 'anon').replace(/\s+/g,'').slice(0,24) || 'anon';
351
promptEl.textContent = nick + '@chat:~$';
352
}
353
function randomNick(){
268
- // Programming-meme themed nickname
269
- const techs = ['gopher','rustacean','nixer','unix','kernel','docker','kube','vim','emacs','tmux','nvim','git','linux','bsd','wasm','grpc','lambda','pointer','monad','segfault','null','byte','packet','devops','cli'];
270
- const roles = ['wizard','hacker','guru','daemon','runner','scripter','shell','warrior','artisan','smith'];
271
- const a = techs[Math.floor(Math.random()*techs.length)];
272
- const b = roles[Math.floor(Math.random()*roles.length)];
273
- const id = Math.random().toString(36).slice(2,6);
274
- return a + '-' + b + '-' + id;
354
+ // Short nickname: one word + 4-digit number
355
+ const words = ['gopher','rust','unix','kernel','docker','kube','vim','emacs','tmux','nvim','git','linux','bsd','wasm','grpc','lambda','pointer','monad','null','byte','packet','devops','cli'];
356
+ const w = words[Math.floor(Math.random()*words.length)];
357
+ const num = Math.floor(Math.random()*9000) + 1000; // 4-digit
358
+ return w + '-' + num;
359
}
360
+ // Stable client UID per browser (per origin)
361
+ function genUID(){ try{ return (crypto.randomUUID && crypto.randomUUID()) || '' }catch(_){ return '' } }
362
+ function fallbackUID(){ return Math.random().toString(36).slice(2) + Date.now().toString(36) }
363
+ let clientUID = null;
364
+ try { clientUID = localStorage.getItem('relaydns_uid'); } catch(_) {}
365
+ if(!clientUID || clientUID.length < 8){ clientUID = genUID() || fallbackUID(); try { localStorage.setItem('relaydns_uid', clientUID); } catch(_) {} }
366
+
367
// Restore nickname or initialize randomly
368
let savedNick = null;
369
try { savedNick = localStorage.getItem('relaydns_nick'); } catch(_) {}
370
if(savedNick){
280
- user.value = savedNick;
371
+ const oldPattern = /^[a-z]+-[a-z]+-[0-9a-z]{2,}$/i.test(savedNick);
372
+ if (oldPattern) {
373
+ user.value = randomNick();
374
+ try { localStorage.setItem('relaydns_nick', user.value); } catch(_) {}
375
+ } else {
376
+ user.value = savedNick;
377
+ }
378
} else {
379
user.value = randomNick();
380
try { localStorage.setItem('relaydns_nick', user.value); } catch(_) {}
381
}
382
setPrompt();
286
- user.addEventListener('input', () => { try{ localStorage.setItem('relaydns_nick', user.value); }catch(_){}; setPrompt(); });
383
+ // Debounced notify of nickname changes to server so roster updates without sending a chat
384
+ let nickTimer = null;
385
+ user.addEventListener('input', () => {
386
+ try{ localStorage.setItem('relaydns_nick', user.value); }catch(_){}
387
+ setPrompt();
388
+ if (ws && ws.readyState === 1) {
389
+ if (nickTimer) clearTimeout(nickTimer);
390
+ nickTimer = setTimeout(() => {
391
+ try{ ws.send(JSON.stringify({ user: (user.value || 'anon'), text: '', uid: clientUID })); }catch(_){ }
392
+ }, 300);
393
+ }
394
+ });
395
roll.addEventListener('click', () => {
396
user.value = randomNick();
397
try{ localStorage.setItem('relaydns_nick', user.value); }catch(_){}
@@ -306,13 +414,21 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
414
const idx = hashNick(nick || 'anon') % PALETTE.length;
415
return PALETTE[idx];
416
}
417
+ function renderRoster(users){
418
+ const count = (users ? users.length : 0);
419
+ if (usersCount) usersCount.textContent = String(count);
420
+ }
421
function append(msg){
422
const div = document.createElement('div');
423
div.className = 'line';
312
- const ts = new Date(msg.ts).toLocaleTimeString();
424
+ const ts = new Date(msg.ts).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
425
const nick = (msg.user || 'anon');
426
const color = colorFor(nick);
315
- if (msg.event === 'joined' || msg.event === 'left') {
427
+ if (msg.event === 'roster') { renderRoster(msg.users || []); return; }
428
+ if (msg.event === 'rename') {
429
+ div.className = 'line event';
430
+ div.innerHTML = '<span class="ts">[' + ts + ']</span> ' + escapeHTML(msg.user || 'anon') + ' -> ' + escapeHTML(msg.text || '') + ' changed';
431
+ } else if (msg.event === 'joined' || msg.event === 'left') {
432
const verb = msg.event === 'joined' ? 'joined' : 'left';
433
div.className = 'line event';
434
div.innerHTML = '<span class="ts">[' + ts + ']</span> ' + escapeHTML(nick) + ' ' + verb;
@@ -332,10 +448,10 @@ var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
448
const ws = new WebSocket(wsProto + '://' + location.host + basePath + 'ws');
449
ws.onmessage = (e) => { try{ append(JSON.parse(e.data)); }catch(_){ } };
450
ws.onopen = () => {
335
- try{ ws.send(JSON.stringify({ user: (user.value || 'anon'), text: '' })); }catch(_){ }
451
+ try{ ws.send(JSON.stringify({ user: (user.value || 'anon'), text: '', uid: clientUID })); }catch(_){ }
452
};
453
function send(){
338
- const payload = { user: (user.value || 'anon'), text: cmd.value.trim() };
454
+ const payload = { user: (user.value || 'anon'), text: cmd.value.trim(), uid: clientUID };
455
if(!payload.text) return;
456
ws.send(JSON.stringify(payload));
457
cmd.value='';