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='';