From 779718a088e2f39f7b5a98187772384b64e3065c Mon Sep 17 00:00:00 2001 From: Jeff Winkler Date: Tue, 11 Nov 2025 18:30:40 -0500 Subject: [PATCH] Multi-Room Chat (#4) * # This is a combination of 4 commits. # This is the 1st commit message: Chatroom example # This is the commit message #2: Avatar styling # This is the commit message #3: Styling # This is the commit message #4: cleanup * Chatroom example Avatar styling Benchmark tests Cleanup ignore Files Cleanroom chatroom impl * Rewrite. * changes * Fix Deadlocks. Start the rooms. Fix styling. Random things. Bookmarklet. * Subset data * Rm file * Simplify User. Just Comparable. * Remove method. --- .gitignore | 3 + actiontrigger.go | 88 +++++++- go.mod | 8 +- internal/examples/chatroom/ADR.md | 10 + internal/examples/chatroom/main.go | 274 +++++++++++++++++++++++ internal/examples/chatroom/rooms.go | 163 ++++++++++++++ internal/examples/chatroom/rooms_test.go | 100 +++++++++ 7 files changed, 637 insertions(+), 9 deletions(-) create mode 100644 internal/examples/chatroom/ADR.md create mode 100644 internal/examples/chatroom/main.go create mode 100644 internal/examples/chatroom/rooms.go create mode 100644 internal/examples/chatroom/rooms_test.go diff --git a/.gitignore b/.gitignore index 0347222..af4648d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ go.work.sum # Via related .via + +# Air artifacts +*tmp/ diff --git a/actiontrigger.go b/actiontrigger.go index 56dfd09..c198c1a 100644 --- a/actiontrigger.go +++ b/actiontrigger.go @@ -2,6 +2,7 @@ package via import ( "fmt" + "strconv" "github.com/go-via/via/h" ) @@ -11,14 +12,85 @@ type actionTrigger struct { id string } -// OnClick returns a via.h DOM node that triggers on click. It can be added -// to element nodes in a view. -func (a *actionTrigger) OnClick() h.H { - return h.Data("on:click", fmt.Sprintf("@get('/_action/%s')", a.id)) +// ActionTriggerOption configures behavior of action triggers +type ActionTriggerOption interface { + apply(*triggerOpts) } -// OnChange returns a via.h DOM node that triggers on input change. It can be added -// to element nodes in a view. -func (a *actionTrigger) OnChange() h.H { - return h.Data("on:change__debounce.200ms", fmt.Sprintf("@get('/_action/%s')", a.id)) +type triggerOpts struct { + hasSignal bool + signalID string + value string +} + +type withSignalOpt struct { + signalID string + value string +} + +func (o withSignalOpt) apply(opts *triggerOpts) { + opts.hasSignal = true + opts.signalID = o.signalID + opts.value = o.value +} + +// WithSignal sets a signal value before triggering the action. +func WithSignal(sig *signal, value string) ActionTriggerOption { + return withSignalOpt{ + signalID: sig.ID(), + value: fmt.Sprintf("'%s'", value), + } +} + +// WithSignalInt sets a signal to an int value before triggering the action. +func WithSignalInt(sig *signal, value int) ActionTriggerOption { + return withSignalOpt{ + signalID: sig.ID(), + value: strconv.Itoa(value), + } +} + +func buildOnExpr(base string, opts *triggerOpts) string { + if !opts.hasSignal { + return base + } + return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base) +} + +func applyOptions(options ...ActionTriggerOption) triggerOpts { + var opts triggerOpts + for _, opt := range options { + opt.apply(&opts) + } + return opts +} + +func actionURL(id string) string { + return fmt.Sprintf("@get('/_action/%s')", id) +} + +// OnClick returns a via.h DOM attribute that triggers on click. It can be added +// to element nodes in a view. +func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H { + opts := applyOptions(options...) + return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts)) +} + +// OnChange returns a via.h DOM attribute that triggers on input change. It can be added +// to element nodes in a view. +func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H { + opts := applyOptions(options...) + return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) +} + +// OnEnterKey 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 { + opts := applyOptions(options...) + var condition string + if key != "" { + condition = fmt.Sprintf("evt.key==='%s' &&", key) + } + return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts))) } diff --git a/go.mod b/go.mod index 13613bf..ccef8f7 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,17 @@ go 1.25.3 require maragu.dev/gomponents v1.2.0 -require github.com/starfederation/datastar-go v1.0.3 +require ( + github.com/starfederation/datastar-go v1.0.3 + github.com/stretchr/testify v1.10.0 +) require ( github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/examples/chatroom/ADR.md b/internal/examples/chatroom/ADR.md new file mode 100644 index 0000000..e122636 --- /dev/null +++ b/internal/examples/chatroom/ADR.md @@ -0,0 +1,10 @@ +## ADR + +- Support Multiple Rooms +Not single chat room toy problem. + +- Rooms are generic +They know nothing of their data. Just store it. Reusable for different usecases. + +- Server controls push frequency +Debounce to every 400ms, if dirty. diff --git a/internal/examples/chatroom/main.go b/internal/examples/chatroom/main.go new file mode 100644 index 0000000..f23e11c --- /dev/null +++ b/internal/examples/chatroom/main.go @@ -0,0 +1,274 @@ +package main + +import ( + "fmt" + "math/rand" + + "github.com/go-via/via" + "github.com/go-via/via/h" +) + +var ( + WithSignal = via.WithSignal + WithSignalInt = via.WithSignalInt +) + +// To drive heavy traffic: start several browsers and put this in the console: +// setInterval(() => document.querySelector('input').dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true})), 500); +// Or, as a bookmarklet: +// javascript:(function(){setInterval(()=>{const input=document.querySelector('input');if(input){input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}))}},500)})(); + +func main() { + v := via.New() + v.Config(via.Options{ + DevMode: true, + DocumentTitle: "ViaChat", + LogLvl: via.LogLevelInfo, + Plugins: []via.Plugin{via.SigQuitPlugin}, + }) + + v.AppendToHead( + h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")), + h.StyleEl(h.Raw(` + body { margin: 0; } + main { + display: flex; + flex-direction: column; + height: 100vh; + } + nav[role="tab-control"] ul li a[aria-current="page"] { + background-color: var(--pico-primary-background); + color: var(--pico-primary-inverse); + border-bottom: 2px solid var(--pico-primary); + } + .chat-message { display: flex; gap: 0.75rem; } + .avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--pico-muted-border-color); + display: grid; + place-items: center; + font-size: 1.5rem; + } + .bubble { flex: 1; } + .bubble p { margin: 0; } + .chat-history { + flex: 1; + overflow-y: auto; + padding-bottom: calc(88px + env(safe-area-inset-bottom)); + scrollbar-width: none; + } + .chat-history::-webkit-scrollbar { + display: none; + } + .chat-input { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: var(--pico-background-color); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + border-top: 1px solid var(--pico-muted-border-color); + } + .chat-input fieldset { + flex: 1; + margin: 0; + } + `)), h.Script(h.Raw(` + function scrollChatToBottom() { + const chatHistory = document.querySelector('.chat-history'); + if (chatHistory) chatHistory.scrollTop = chatHistory.scrollHeight; + } + setInterval(scrollChatToBottom, 100); + `)), + ) + rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust") + rooms.Start() + + v.Page("/", func(c *via.Context) { + roomName := c.Signal("Go") + + // Need to be careful about reading signals: can cause race conditions. + // So use a string as much as possible. + var roomNameString string + currentUser := NewUserInfo(randAnimal()) + statement := c.Signal("") + + var currentRoom *Room[Chat, UserInfo] + + switchRoom := func() { + newRoom, _ := rooms.Get(string(roomName.String())) + fmt.Println(">> switchRoom to ", newRoom.Name) + if currentRoom != nil && currentRoom != newRoom { + fmt.Println("LEAVING old room") + currentRoom.Leave(¤tUser) + } + newRoom.Join(&UserAndSync[Chat, UserInfo]{user: ¤tUser, sync: c}) + currentRoom = newRoom + roomNameString = newRoom.Name + c.Sync() + } + + switchRoomAction := c.Action(func() { + switchRoom() + }) + + switchRoom() + + say := c.Action(func() { + msg := statement.String() + if msg == "" { + // For testing, generate random stuff. + msg = thingsDevsSay() + } else { + statement.SetValue("") + } + if currentRoom != nil { + currentRoom.UpdateData(func(chat *Chat) { + chat.Entries = append(chat.Entries, ChatEntry{ + User: currentUser, + Message: msg, + }) + }) + statement.SetValue("") + // Update the UI right away so feels snappy. + c.Sync() + } + }) + + c.View(func() h.H { + var tabs []h.H + rooms.Visit(func(n string) { + if n == roomNameString { + tabs = append(tabs, h.Li( + h.A( + h.Href(""), + h.Attr("aria-current", "page"), + h.Text(n), + switchRoomAction.OnClick(WithSignal(roomName, n)), + ), + )) + } else { + tabs = append(tabs, h.Li( + h.A( + h.Href("#"), + h.Text(n), + switchRoomAction.OnClick(via.WithSignal(roomName, n)), + ), + )) + } + }) + + var messages []h.H + if currentRoom != nil { + chat := currentRoom.GetData(func(c *Chat) Chat { + n := len(c.Entries) + start := n - 50 + if start < 0 { + start = 0 + } + trimmed := make([]ChatEntry, n-start) + copy(trimmed, c.Entries[start:]) + out := *c + out.Entries = trimmed + return out + }) + for _, entry := range chat.Entries { + + messageChildren := []h.H{h.Class("chat-message"), entry.User.Avatar()} + messageChildren = append(messageChildren, + h.Div(h.Class("bubble"), + h.P(h.Text(entry.Message)), + ), + ) + + messages = append(messages, h.Div(messageChildren...)) + } + } + + chatHistory := []h.H{h.Class("chat-history")} + chatHistory = append(chatHistory, messages...) + + return h.Main(h.Class("container"), + h.Nav( + h.Attr("role", "tab-control"), + h.Ul(tabs...), + ), + h.Div(chatHistory...), + h.Div( + h.Class("chat-input"), + currentUser.Avatar(), + h.FieldSet( + h.Attr("role", "group"), + h.Input( + h.Type("text"), + h.Placeholder(fmt.Sprintf("%s says...", currentUser.Name)), + statement.Bind(), + h.Attr("autofocus"), + say.OnKeyDown("Enter"), + ), + h.Button(h.Text("Say"), say.OnClick()), + ), + ), + ) + }) + }) + + v.Start() +} + +type UserInfo struct { + Name string + emoji string +} + +func NewUserInfo(name, emoji string) UserInfo { + return UserInfo{Name: name, emoji: emoji} +} + +func (u *UserInfo) Avatar() h.H { + return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.emoji)) +} + +type ChatEntry struct { + User UserInfo + Message string +} + +type Chat struct { + Entries []ChatEntry +} + +func randAnimal() (string, string) { + adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"} + + animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"} + whichAnimal := rand.Intn(len(animals)) + + emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"} + return adjectives[rand.Intn(len(adjectives))] + " " + animals[whichAnimal], emojis[whichAnimal] +} + +var thingIdx = rand.Intn(len(things)) - 1 +var things = []string{"I like turtles.", "How do you clean up signals?", "Just use Lisp.", "You're complecting things.", + "The internet is a series of tubes.", "Go is not a good language.", "I love Python.", "JavaScript is everywhere.", "Kotlin is great for Android.", + "Rust is memory safe.", "Dotnet is cross platform.", "Rewrite it in Rust", "Is it web scale?", "PRs welcome.", "Have you tried turning it off and on again?", + "Clojure has macros.", "Functional programming is the future.", "OOP is dead.", "Tabs are better than spaces.", "Spaces are better than tabs.", + "I use Emacs.", "Vim is the best editor.", "VSCode is bloated.", "I code in the browser.", "Serverless is the way to go.", "Containers are lightweight VMs.", + "Microservices are the future.", "Monoliths are easier to manage.", "Agile is just Scrum.", "Waterfall still has its place.", "DevOps is a culture.", "CI/CD is essential.", + "Testing is important.", "TDD saves time.", "BDD improves communication.", "Documentation is key.", "APIs should be RESTful.", "GraphQL is flexible.", "gRPC is efficient.", + "WebAssembly is the future of web apps.", "Progressive Web Apps are great.", "Single Page Applications can be overkill.", "Jamstack is modern web development.", + "CDNs improve performance.", "Edge computing reduces latency.", "5G will change everything.", "AI will take over coding.", "Machine learning is powerful.", + "Data science is in demand.", "Big data requires big storage.", "Cloud computing is ubiquitous.", "Hybrid cloud offers flexibility.", "Multi-cloud avoids vendor lock-in.", + "That can't possibly work", "First!", "Leeroy Jenkins!", "I love open source.", "Closed source has its place.", "Licensing is complicated."} + +func thingsDevsSay() string { + + thingIdx = (thingIdx + 1) % len(things) + return things[thingIdx] + +} diff --git a/internal/examples/chatroom/rooms.go b/internal/examples/chatroom/rooms.go new file mode 100644 index 0000000..ba31ab7 --- /dev/null +++ b/internal/examples/chatroom/rooms.go @@ -0,0 +1,163 @@ +package main + +import ( + "fmt" + "sync" + "time" +) + +type Syncable interface { + Sync() +} +type UserAndSync[TR any, TU comparable] struct { + user *TU + sync Syncable +} +type Rooms[TR any, TU comparable] struct { + byName map[string]*Room[TR, TU] + names []string +} + +func (rs *Rooms[TR, TU]) Visit(fn func(n string)) { + for _, n := range rs.names { + fn(n) + } +} + +func (rs *Rooms[TR, TU]) Get(n string) (*Room[TR, TU], bool) { + rm, ok := rs.byName[n] + return rm, ok +} + +func (rs *Rooms[TR, TU]) Start() { + for _, rm := range rs.byName { + go rm.run() + } +} + +func (rs *Rooms[TR, TU]) Stop() { + for _, rm := range rs.byName { + rm.stop() + } +} + +// NewRooms seeds the rooms once at startup. +// Assumptions: rooms don't change. Should be sorted by name. +func NewRooms[TR any, TU comparable](names ...string) Rooms[TR, TU] { + byName := make(map[string]*Room[TR, TU]) + for _, n := range names { + byName[n] = NewRoom[TR, TU](n) + } + + return Rooms[TR, TU]{byName, names} +} + +type Room[TR any, TU comparable] struct { + data TR + dataMu sync.RWMutex + members map[TU]Syncable + membersMu sync.RWMutex + Name string + join chan *UserAndSync[TR, TU] + leave chan *TU + done chan struct{} + stopChannel chan struct{} + dirty bool +} + +// UpdateData lets the calling function update the room data. +// Is called with a write lock - so should be *fast* +func (r *Room[TR, TU]) UpdateData(fn func(data *TR)) { + r.dataMu.Lock() + defer r.dataMu.Unlock() + fn(&r.data) + r.dirty = true +} + +func (r *Room[TR, TU]) Publish() { + r.dataMu.Lock() + if !r.dirty { + r.dataMu.Unlock() + return + } + + publishers := make([]Syncable, 0, len(r.members)) + for _, sync := range r.members { + publishers = append(publishers, sync) + } + r.dirty = false + r.dataMu.Unlock() + + // Now call Sync without holding the lock + for _, sync := range publishers { + sync.Sync() + } +} + +// Get room data. This is a copy. +// Accepts an optional subset function to transform data before copying. +func (r *Room[TR, TU]) GetData(subsetFn ...func(*TR) TR) TR { + r.dataMu.RLock() + defer r.dataMu.RUnlock() + + if len(subsetFn) == 0 || subsetFn[0] == nil { + return r.data + } + + tmp := r.data + return subsetFn[0](&tmp) +} + +func (r *Room[TR, TU]) Join(us *UserAndSync[TR, TU]) { + r.join <- us +} + +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, + join: make(chan *UserAndSync[TR, TU], 5), + leave: make(chan *TU, 5), + stopChannel: make(chan struct{}), + done: make(chan struct{}), + members: make(map[TU]Syncable), + } +} + +func (r *Room[TR, TU]) run() { + defer close(r.done) + publishTicker := time.NewTicker(400 * time.Millisecond) + defer publishTicker.Stop() + for { + select { + case usrAndSync := <-r.join: + fmt.Println("Joining: ", *usrAndSync.user) + r.membersMu.Lock() + r.members[*usrAndSync.user] = usrAndSync.sync + r.membersMu.Unlock() + case usr := <-r.leave: + fmt.Println("Leaving: ", *usr) + r.membersMu.Lock() + delete(r.members, *usr) + r.membersMu.Unlock() + case <-publishTicker.C: + r.Publish() + case <-r.stopChannel: + return // exit goroutine + } + } +} + +func (r *Room[TR, TU]) stop() { + close(r.stopChannel) + <-r.done // wait for run() to finish +} diff --git a/internal/examples/chatroom/rooms_test.go b/internal/examples/chatroom/rooms_test.go new file mode 100644 index 0000000..b3c016b --- /dev/null +++ b/internal/examples/chatroom/rooms_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type Statement struct { + text string + author TestUserInfo +} + +type RoomData struct { + convo []Statement +} + +type TestUserInfo struct { + Name string +} + +func TestRoomsZero(t *testing.T) { + rooms := NewRooms[RoomData, TestUserInfo]() + assert.NotNil(t, rooms) +} + +func TestRoomsMany(t *testing.T) { + names := []string{"a", "b"} + rooms := NewRooms[RoomData, TestUserInfo](names...) + assert.NotNil(t, rooms) + assert.Equal(t, 2, len(rooms.names)) + + // Visit + seen := 0 + rooms.Visit(func(name string) { seen++ }) + assert.Equal(t, seen, 2) + + // GetRoom fail + _, ok := rooms.Get("z") + assert.False(t, ok) + // GetRoom + rm, ok := rooms.Get("a") + assert.True(t, ok) + assert.NotNil(t, rm) + assert.Equal(t, string("a"), rm.Name) +} + +type DummySyncable struct { + room *Room[RoomData, TestUserInfo] + timesCalled int +} + +func (ds *DummySyncable) Sync() { + // Data() hits deadlock conditions from Publish() + ds.room.GetData() + ds.timesCalled++ +} + +func TestRoomJoinLeaveChannels(t *testing.T) { + rooms := NewRooms[RoomData, TestUserInfo](string("a")) + rm, _ := rooms.Get("a") + u1 := TestUserInfo{"Bob"} + u1Context := DummySyncable{room: rm} + + rooms.Start() + defer rooms.Stop() + uas := UserAndSync[RoomData, TestUserInfo]{user: &u1, sync: &u1Context} + + // Joining a room does *not* mark it dirty. It's on the user to call Sync() - + // so the user gets the update immediately. + rm.Join(&uas) + + // // Give it time to process + time.Sleep(1 * time.Millisecond) + + assert.Equal(t, rm.dirty, false) + assert.Equal(t, rm.MemberCount(), 1) + + // Room Data + rm.UpdateData(func(data *RoomData) { + data.convo = append(data.convo, Statement{"Hello", u1}) + }) + assert.Equal(t, rm.dirty, true) + + data := rm.GetData() + assert.Equal(t, len(data.convo), 1) + + // BROADCAST to connected users. Clears the dirty flag. + rm.Publish() + time.Sleep(1 * time.Millisecond) + assert.Equal(t, rm.dirty, false) + assert.Equal(t, u1Context.timesCalled, 1) + + // Leave + rm.Leave(&u1) + time.Sleep(1 * time.Millisecond) + + assert.Equal(t, rm.MemberCount(), 0) +}