Chatroom 2 (#10)

* 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 <joao.goncalves01@gmail.com>
This commit is contained in:
Jeff Winkler
2025-11-13 10:39:37 -05:00
committed by GitHub
parent 7670926733
commit 351bed3ea1
7 changed files with 34 additions and 46 deletions

View File

@@ -83,7 +83,7 @@ func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) 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 // key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
// Example: OnKeyDown("Enter") // Example: OnKeyDown("Enter")
func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H {

View File

@@ -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. // 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) type Plugin func(v *V)
// Config defines configuration options for the via application. // Options defines configuration options for the via application
type Options struct { type Options struct {
// The development mode flag. If true, enables server and browser auto-reload on `.go` file changes. // The development mode flag. If true, enables server and browser auto-reload on `.go` file changes.
DevMode bool DevMode bool

View File

@@ -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 func (c *Context) getSSE() *datastar.ServerSentEventGenerator {
// over the live SSE event stream.
func (c *Context) Sync() {
// components use parent page sse stream // components use parent page sse stream
var sse *datastar.ServerSentEventGenerator var sse *datastar.ServerSentEventGenerator
if c.isComponent() { if c.isComponent() {
@@ -192,6 +190,13 @@ func (c *Context) Sync() {
} else { } else {
sse = c.sse 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 { if sse == nil {
c.app.logWarn(c, "view out of sync: no sse stream") c.app.logWarn(c, "view out of sync: no sse stream")
return 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 // Then, the merge will only occur if the ID of the top level element in the patch
// matches 'my-element'. // matches 'my-element'.
func (c *Context) SyncElements(elem h.H) { func (c *Context) SyncElements(elem h.H) {
var sse *datastar.ServerSentEventGenerator sse := c.getSSE()
if c.isComponent() {
sse = c.parentPageCtx.sse
} else {
sse = c.sse
}
if sse == nil { if sse == nil {
c.app.logWarn(c, "elements out of sync: no sse stream") c.app.logWarn(c, "elements out of sync: no sse stream")
return return
@@ -260,12 +260,7 @@ func (c *Context) SyncElements(elem h.H) {
// SyncSignals pushes the current signal changes to the browser immediately // SyncSignals pushes the current signal changes to the browser immediately
// over the live SSE event stream. // over the live SSE event stream.
func (c *Context) SyncSignals() { func (c *Context) SyncSignals() {
var sse *datastar.ServerSentEventGenerator sse := c.getSSE()
if c.isComponent() {
sse = c.parentPageCtx.sse
} else {
sse = c.sse
}
if sse == nil { if sse == nil {
c.app.logWarn(c, "signals out of sync: no sse stream") c.app.logWarn(c, "signals out of sync: no sse stream")
return return
@@ -285,12 +280,7 @@ func (c *Context) SyncSignals() {
} }
func (c *Context) ExecScript(s string) { func (c *Context) ExecScript(s string) {
var sse *datastar.ServerSentEventGenerator sse := c.getSSE()
if c.isComponent() {
sse = c.parentPageCtx.sse
} else {
sse = c.sse
}
if sse == nil { if sse == nil {
c.app.logWarn(c, "script out of sync: no sse stream") c.app.logWarn(c, "script out of sync: no sse stream")
return return

2
h/h.go
View File

@@ -31,7 +31,7 @@ func Textf(format string, a ...any) H {
return g.Textf(format, a...) 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 { func Raw(s string) H {
return g.Raw(s) return g.Raw(s)
} }

View File

@@ -80,9 +80,8 @@ func main() {
`)), h.Script(h.Raw(` `)), h.Script(h.Raw(`
function scrollChatToBottom() { function scrollChatToBottom() {
const chatHistory = document.querySelector('.chat-history'); 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") rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust")
@@ -100,7 +99,10 @@ func main() {
var currentRoom *Room[Chat, UserInfo] var currentRoom *Room[Chat, UserInfo]
switchRoom := func() { switchRoom := func() {
newRoom, _ := rooms.Get(string(roomName.String())) newRoom, ok := rooms.Get(string(roomName.String()))
if !ok {
return
}
fmt.Println(">> switchRoom to ", newRoom.Name) fmt.Println(">> switchRoom to ", newRoom.Name)
if currentRoom != nil && currentRoom != newRoom { if currentRoom != nil && currentRoom != newRoom {
fmt.Println("LEAVING old room") fmt.Println("LEAVING old room")
@@ -129,8 +131,8 @@ func main() {
if currentRoom != nil { if currentRoom != nil {
currentRoom.UpdateData(func(chat *Chat) { currentRoom.UpdateData(func(chat *Chat) {
chat.Entries = append(chat.Entries, ChatEntry{ chat.Entries = append(chat.Entries, ChatEntry{
User: currentUser, user: currentUser,
Message: msg, message: msg,
}) })
}) })
statement.SetValue("") statement.SetValue("")
@@ -178,10 +180,10 @@ func main() {
}) })
for _, entry := range chat.Entries { 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, messageChildren = append(messageChildren,
h.Div(h.Class("bubble"), 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...) chatHistory = append(chatHistory, messages...)
return h.Main(h.Class("container"), return h.Main(h.Class("container"),
@@ -205,7 +210,7 @@ func main() {
h.Attr("role", "group"), h.Attr("role", "group"),
h.Input( h.Input(
h.Type("text"), h.Type("text"),
h.Placeholder(fmt.Sprintf("%s says...", currentUser.Name)), h.Placeholder(currentUser.Name+" says..."),
statement.Bind(), statement.Bind(),
h.Attr("autofocus"), h.Attr("autofocus"),
say.OnKeyDown("Enter"), say.OnKeyDown("Enter"),
@@ -234,8 +239,8 @@ func (u *UserInfo) Avatar() h.H {
} }
type ChatEntry struct { type ChatEntry struct {
User UserInfo user UserInfo
Message string message string
} }
type Chat struct { type Chat struct {

View File

@@ -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. // Accepts an optional subset function to transform data before copying.
func (r *Room[TR, TU]) GetData(subsetFn ...func(*TR) TR) TR { func (r *Room[TR, TU]) GetData(subsetFn ...func(*TR) TR) TR {
r.dataMu.RLock() r.dataMu.RLock()
@@ -116,12 +116,6 @@ func (r *Room[TR, TU]) Leave(u *TU) {
r.leave <- u 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] { func NewRoom[TR any, TU comparable](n string) *Room[TR, TU] {
return &Room[TR, TU]{ return &Room[TR, TU]{
Name: n, Name: n,
@@ -135,7 +129,7 @@ func NewRoom[TR any, TU comparable](n string) *Room[TR, TU] {
func (r *Room[TR, TU]) run() { func (r *Room[TR, TU]) run() {
defer close(r.done) defer close(r.done)
publishTicker := time.NewTicker(400 * time.Millisecond) publishTicker := time.NewTicker(250 * time.Millisecond)
defer publishTicker.Stop() defer publishTicker.Stop()
for { for {
select { select {

View File

@@ -56,7 +56,6 @@ func (ds *DummySyncable) Sync() {
ds.room.GetData() ds.room.GetData()
ds.timesCalled++ ds.timesCalled++
} }
func TestRoomJoinLeaveChannels(t *testing.T) { func TestRoomJoinLeaveChannels(t *testing.T) {
rooms := NewRooms[RoomData, TestUserInfo](string("a")) rooms := NewRooms[RoomData, TestUserInfo](string("a"))
rm, _ := rooms.Get("a") rm, _ := rooms.Get("a")
@@ -75,7 +74,7 @@ func TestRoomJoinLeaveChannels(t *testing.T) {
time.Sleep(1 * time.Millisecond) time.Sleep(1 * time.Millisecond)
assert.Equal(t, rm.dirty, false) assert.Equal(t, rm.dirty, false)
assert.Equal(t, rm.MemberCount(), 1) assert.Equal(t, len(rm.members), 1)
// Room Data // Room Data
rm.UpdateData(func(data *RoomData) { rm.UpdateData(func(data *RoomData) {
@@ -96,5 +95,5 @@ func TestRoomJoinLeaveChannels(t *testing.T) {
rm.Leave(&u1) rm.Leave(&u1)
time.Sleep(1 * time.Millisecond) time.Sleep(1 * time.Millisecond)
assert.Equal(t, rm.MemberCount(), 0) assert.Equal(t, len(rm.members), 0)
} }