From 351bed3ea1a5b16083b26817d6d20f769376b868 Mon Sep 17 00:00:00 2001 From: Jeff Winkler Date: Thu, 13 Nov 2025 10:39:37 -0500 Subject: [PATCH] Chatroom 2 (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove unused method. Don't panic if unknown room. * Need a Connected() check for rooms publishing - don't do the work of rendering for a dead connection * Make vars private. * Linter issues * Remove Connected() * Mutation observer. Publish 4x / second. --------- Co-authored-by: João Gonçalves --- actiontrigger.go | 2 +- configuration.go | 2 +- context.go | 32 ++++++++---------------- h/h.go | 2 +- internal/examples/chatroom/main.go | 27 ++++++++++++-------- internal/examples/chatroom/rooms.go | 10 ++------ internal/examples/chatroom/rooms_test.go | 5 ++-- 7 files changed, 34 insertions(+), 46 deletions(-) diff --git a/actiontrigger.go b/actiontrigger.go index 126ba21..5749570 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -83,7 +83,7 @@ func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H { return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) } -// OneyDown returns a via.h DOM attribute that triggers when a key is pressed. +// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed. // key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key // Example: OnKeyDown("Enter") func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H { diff --git a/configuration.go b/configuration.go index 10a53bc..8316344 100644 --- a/configuration.go +++ b/configuration.go @@ -13,7 +13,7 @@ const ( // Plugin is a func that can mutate the given *via.V app runtime. It is useful to integrate popular JS/CSS UI libraries or tools. type Plugin func(v *V) -// Config defines configuration options for the via application. +// Options defines configuration options for the via application type Options struct { // The development mode flag. If true, enables server and browser auto-reload on `.go` file changes. DevMode bool diff --git a/context.go b/context.go index a15cfc4..8b12f65 100644 --- a/context.go +++ b/context.go @@ -182,9 +182,7 @@ func (c *Context) injectSignals(sigs map[string]any) { } } -// Sync pushes the current view state and signal changes to the browser immediately -// over the live SSE event stream. -func (c *Context) Sync() { +func (c *Context) getSSE() *datastar.ServerSentEventGenerator { // components use parent page sse stream var sse *datastar.ServerSentEventGenerator if c.isComponent() { @@ -192,6 +190,13 @@ func (c *Context) Sync() { } else { sse = c.sse } + return sse +} + +// Sync pushes the current view state and signal changes to the browser immediately +// over the live SSE event stream. +func (c *Context) Sync() { + sse := c.getSSE() if sse == nil { c.app.logWarn(c, "view out of sync: no sse stream") return @@ -234,12 +239,7 @@ func (c *Context) Sync() { // Then, the merge will only occur if the ID of the top level element in the patch // matches 'my-element'. func (c *Context) SyncElements(elem h.H) { - var sse *datastar.ServerSentEventGenerator - if c.isComponent() { - sse = c.parentPageCtx.sse - } else { - sse = c.sse - } + sse := c.getSSE() if sse == nil { c.app.logWarn(c, "elements out of sync: no sse stream") return @@ -260,12 +260,7 @@ func (c *Context) SyncElements(elem h.H) { // SyncSignals pushes the current signal changes to the browser immediately // over the live SSE event stream. func (c *Context) SyncSignals() { - var sse *datastar.ServerSentEventGenerator - if c.isComponent() { - sse = c.parentPageCtx.sse - } else { - sse = c.sse - } + sse := c.getSSE() if sse == nil { c.app.logWarn(c, "signals out of sync: no sse stream") return @@ -285,12 +280,7 @@ func (c *Context) SyncSignals() { } func (c *Context) ExecScript(s string) { - var sse *datastar.ServerSentEventGenerator - if c.isComponent() { - sse = c.parentPageCtx.sse - } else { - sse = c.sse - } + sse := c.getSSE() if sse == nil { c.app.logWarn(c, "script out of sync: no sse stream") return diff --git a/h/h.go b/h/h.go index e521b2a..03c2e62 100644 --- a/h/h.go +++ b/h/h.go @@ -31,7 +31,7 @@ func Textf(format string, a ...any) H { return g.Textf(format, a...) } -// / Raw creates a text DOM [Node] that just Renders the unescaped string t. +// Raw creates a text DOM [Node] that just Renders the unescaped string t. func Raw(s string) H { return g.Raw(s) } diff --git a/internal/examples/chatroom/main.go b/internal/examples/chatroom/main.go index 700220f..641845c 100644 --- a/internal/examples/chatroom/main.go +++ b/internal/examples/chatroom/main.go @@ -80,9 +80,8 @@ func main() { `)), h.Script(h.Raw(` function scrollChatToBottom() { const chatHistory = document.querySelector('.chat-history'); - if (chatHistory) chatHistory.scrollTop = chatHistory.scrollHeight; + chatHistory.scrollTop = chatHistory.scrollHeight; } - setInterval(scrollChatToBottom, 100); `)), ) rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust") @@ -100,7 +99,10 @@ func main() { var currentRoom *Room[Chat, UserInfo] switchRoom := func() { - newRoom, _ := rooms.Get(string(roomName.String())) + newRoom, ok := rooms.Get(string(roomName.String())) + if !ok { + return + } fmt.Println(">> switchRoom to ", newRoom.Name) if currentRoom != nil && currentRoom != newRoom { fmt.Println("LEAVING old room") @@ -129,8 +131,8 @@ func main() { if currentRoom != nil { currentRoom.UpdateData(func(chat *Chat) { chat.Entries = append(chat.Entries, ChatEntry{ - User: currentUser, - Message: msg, + user: currentUser, + message: msg, }) }) statement.SetValue("") @@ -178,10 +180,10 @@ func main() { }) for _, entry := range chat.Entries { - messageChildren := []h.H{h.Class("chat-message"), entry.User.Avatar()} + messageChildren := []h.H{h.Class("chat-message"), entry.user.Avatar()} messageChildren = append(messageChildren, h.Div(h.Class("bubble"), - h.P(h.Text(entry.Message)), + h.P(h.Text(entry.message)), ), ) @@ -189,7 +191,10 @@ func main() { } } - chatHistory := []h.H{h.Class("chat-history")} + chatHistory := []h.H{ + h.Class("chat-history"), + h.Script(h.Raw(`new MutationObserver((mutations)=>{scrollChatToBottom()}).observe(document.querySelector('.chat-history'), {childList:true})`)), + } chatHistory = append(chatHistory, messages...) return h.Main(h.Class("container"), @@ -205,7 +210,7 @@ func main() { h.Attr("role", "group"), h.Input( h.Type("text"), - h.Placeholder(fmt.Sprintf("%s says...", currentUser.Name)), + h.Placeholder(currentUser.Name+" says..."), statement.Bind(), h.Attr("autofocus"), say.OnKeyDown("Enter"), @@ -234,8 +239,8 @@ func (u *UserInfo) Avatar() h.H { } type ChatEntry struct { - User UserInfo - Message string + user UserInfo + message string } type Chat struct { diff --git a/internal/examples/chatroom/rooms.go b/internal/examples/chatroom/rooms.go index ba31ab7..dc9e0e2 100644 --- a/internal/examples/chatroom/rooms.go +++ b/internal/examples/chatroom/rooms.go @@ -94,7 +94,7 @@ func (r *Room[TR, TU]) Publish() { } } -// Get room data. This is a copy. +// GetData returns a copy of room data. // Accepts an optional subset function to transform data before copying. func (r *Room[TR, TU]) GetData(subsetFn ...func(*TR) TR) TR { r.dataMu.RLock() @@ -116,12 +116,6 @@ func (r *Room[TR, TU]) Leave(u *TU) { r.leave <- u } -func (r *Room[TR, TU]) MemberCount() int { - r.membersMu.RLock() - defer r.membersMu.RUnlock() - return len(r.members) -} - func NewRoom[TR any, TU comparable](n string) *Room[TR, TU] { return &Room[TR, TU]{ Name: n, @@ -135,7 +129,7 @@ func NewRoom[TR any, TU comparable](n string) *Room[TR, TU] { func (r *Room[TR, TU]) run() { defer close(r.done) - publishTicker := time.NewTicker(400 * time.Millisecond) + publishTicker := time.NewTicker(250 * time.Millisecond) defer publishTicker.Stop() for { select { diff --git a/internal/examples/chatroom/rooms_test.go b/internal/examples/chatroom/rooms_test.go index b3c016b..ec1ca49 100644 --- a/internal/examples/chatroom/rooms_test.go +++ b/internal/examples/chatroom/rooms_test.go @@ -56,7 +56,6 @@ func (ds *DummySyncable) Sync() { ds.room.GetData() ds.timesCalled++ } - func TestRoomJoinLeaveChannels(t *testing.T) { rooms := NewRooms[RoomData, TestUserInfo](string("a")) rm, _ := rooms.Get("a") @@ -75,7 +74,7 @@ func TestRoomJoinLeaveChannels(t *testing.T) { time.Sleep(1 * time.Millisecond) assert.Equal(t, rm.dirty, false) - assert.Equal(t, rm.MemberCount(), 1) + assert.Equal(t, len(rm.members), 1) // Room Data rm.UpdateData(func(data *RoomData) { @@ -96,5 +95,5 @@ func TestRoomJoinLeaveChannels(t *testing.T) { rm.Leave(&u1) time.Sleep(1 * time.Millisecond) - assert.Equal(t, rm.MemberCount(), 0) + assert.Equal(t, len(rm.members), 0) }