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.
This commit is contained in:
Jeff Winkler
2025-11-11 18:30:40 -05:00
committed by GitHub
parent 5e9740813b
commit 779718a088
7 changed files with 637 additions and 9 deletions

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ go.work.sum
# Via related
.via
# Air artifacts
*tmp/

View File

@@ -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)))
}

8
go.mod
View File

@@ -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
)

View File

@@ -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.

View File

@@ -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(&currentUser)
}
newRoom.Join(&UserAndSync[Chat, UserInfo]{user: &currentUser, 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]
}

View File

@@ -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
}

View File

@@ -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)
}