3 Commits

Author SHA1 Message Date
Ryan Hamamura
6d078f3adb feat: add connection status indicator with SSE heartbeat
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 26s
CI / Deploy / deploy (pull_request) Has been skipped
- Add ConnectionIndicator component showing green/red dot
- Send lastPing signal every 15 seconds via SSE
- Indicator turns red if no ping received in 20 seconds
- Gives users confidence the live connection is active
2026-03-03 09:54:31 -10:00
ffbff8cca5 Merge pull request 'Simplify chat subscription API' (#9) from refactor/chat-subscribe-messages into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m25s
2026-03-03 19:54:21 +00:00
Ryan Hamamura
bcb1fa3872 refactor: simplify chat subscription API
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Room.Subscribe() now returns a channel of parsed Message structs
instead of raw NATS messages. The room handles NATS subscription
and message parsing internally, so callers no longer need to call
Receive() separately.
2026-03-03 09:45:56 -10:00
3 changed files with 37 additions and 31 deletions

View File

@@ -76,12 +76,11 @@ func (r *Room) Send(msg Message) {
}
}
// Receive processes an incoming NATS message, appending it to the buffer.
// Returns the new message and a snapshot of all messages.
func (r *Room) Receive(data []byte) (Message, []Message) {
// receive processes an incoming NATS message, appending it to the buffer.
func (r *Room) receive(data []byte) (Message, bool) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil
return msg, false
}
r.mu.Lock()
@@ -89,11 +88,9 @@ func (r *Room) Receive(data []byte) (Message, []Message) {
if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:]
}
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
r.mu.Unlock()
return msg, snapshot
return msg, true
}
// Messages returns a snapshot of the current message buffer.
@@ -105,15 +102,32 @@ func (r *Room) Messages() []Message {
return snapshot
}
// Subscribe creates a NATS channel subscription for the room's subject.
// Caller is responsible for unsubscribing.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
ch := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch)
// Subscribe returns a channel of parsed messages and a cleanup function.
// The room handles NATS subscription internally and buffers messages.
func (r *Room) Subscribe() (<-chan Message, func()) {
natsCh := make(chan *nats.Msg, 64)
msgCh := make(chan Message, 64)
sub, err := r.nc.ChanSubscribe(r.subject, natsCh)
if err != nil {
return nil, nil, err
close(msgCh)
return msgCh, func() {}
}
return ch, sub, nil
go func() {
for natsMsg := range natsCh {
if msg, ok := r.receive(natsMsg.Data); ok {
msgCh <- msg
}
}
close(msgCh)
}()
cleanup := func() {
_ = sub.Unsubscribe()
}
return msgCh, cleanup
}
func (r *Room) saveMessage(msg Message) {

View File

@@ -142,11 +142,8 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
ctx := r.Context()
for {
@@ -161,8 +158,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
if err := patchAll(); err != nil {
return
}
case msg := <-chatCh:
chatMsg, _ := room.Receive(msg.Data)
case chatMsg := <-chatCh:
err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"),

View File

@@ -148,15 +148,12 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
defer gameSub.Unsubscribe() //nolint:errcheck
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
var chatCh <-chan chat.Message
var cleanupChat func()
if room != nil {
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
chatCh, cleanupChat = room.Subscribe()
defer cleanupChat()
}
ctx := r.Context()
@@ -184,11 +181,10 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return
}
case msg := <-chatCh:
if msg == nil {
case chatMsg, ok := <-chatCh:
if !ok {
continue
}
chatMsg, _ := room.Receive(msg.Data)
err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("snake-chat-history"),