refactor: extract standalone chat package from game-specific handlers
Some checks failed
CI / Deploy / test (pull_request) Failing after 11s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped

Create chat/ package with Message type, Room (NATS pub/sub + buffer),
DB persistence helpers, and a unified templ component parameterized by
Config (CSS prefix, post URL, color function, key propagation).

Both c4game and snakegame now use chat.Room for message management and
chatcomponents.Chat for rendering, eliminating the duplicated
ChatMessage types, chat templ components, chatAutoScroll scripts,
color functions, and inline buffer management.
This commit is contained in:
Ryan Hamamura
2026-03-02 19:20:21 -10:00
parent 7eadfbbb0c
commit 10de5d21ad
10 changed files with 305 additions and 287 deletions

92
chat/chat.go Normal file
View File

@@ -0,0 +1,92 @@
// Package chat provides a reusable chat room backed by NATS pub/sub
// with optional database persistence.
package chat
import (
"encoding/json"
"sync"
"github.com/nats-io/nats.go"
"github.com/rs/zerolog/log"
)
// Message is the wire format for chat messages over NATS.
type Message struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"` // player slot/color index
Message string `json:"message"`
Time int64 `json:"time"` // unix millis, zero for ephemeral messages
}
const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single
// chat room (typically one per game).
type Room struct {
subject string
nc *nats.Conn
messages []Message
mu sync.Mutex
}
// NewRoom creates a chat room that publishes and subscribes on the given
// NATS subject (e.g. "chat.abc123").
func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
return &Room{
subject: subject,
nc: nc,
messages: initial,
}
}
// Send publishes a message to the room's NATS subject.
func (r *Room) Send(msg Message) {
data, err := json.Marshal(msg)
if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
return
}
if err := r.nc.Publish(r.subject, data); err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat 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) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil
}
r.mu.Lock()
r.messages = append(r.messages, msg)
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
}
// Messages returns a snapshot of the current message buffer.
func (r *Room) Messages() []Message {
r.mu.Lock()
defer r.mu.Unlock()
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
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)
if err != nil {
return nil, nil, err
}
return ch, sub, nil
}

View File

@@ -0,0 +1,74 @@
package components
import (
"fmt"
"github.com/ryanhamamura/c4/chat"
"github.com/starfederation/datastar-go/datastar"
)
// ColorFunc resolves a player slot to a CSS color string.
type ColorFunc func(slot int) string
// Config holds the game-specific settings for rendering a chat component.
type Config struct {
// CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake").
CSSPrefix string
// PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat").
PostURL string
// Color resolves a player slot to a CSS color string.
Color ColorFunc
// StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent
// key events from propagating (needed for snake to avoid steering while typing).
StopKeyPropagation bool
}
templ Chat(messages []chat.Message, cfg Config) {
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
<div class={ cfg.CSSPrefix + "-chat-history" }>
for _, m := range messages {
<div class={ cfg.CSSPrefix + "-chat-msg" }>
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", cfg.Color(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
}
</div>
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
if cfg.StopKeyPropagation {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) }
/>
} else {
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.enter={ datastar.PostSSE(cfg.PostURL) }
/>
}
<button
type="button"
data-on:click={ datastar.PostSSE(cfg.PostURL) }
>
Send
</button>
</div>
@chatAutoScroll(cfg.CSSPrefix)
</div>
}
script chatAutoScroll(cssPrefix string) {
var el = document.querySelector('.' + cssPrefix + '-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
}

45
chat/persist.go Normal file
View File

@@ -0,0 +1,45 @@
package chat
import (
"context"
"slices"
"github.com/ryanhamamura/c4/db/repository"
"github.com/rs/zerolog/log"
)
// SaveMessage persists a chat message to the database.
func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: roomID,
Nickname: msg.Nickname,
Color: int64(msg.Slot),
Message: msg.Message,
CreatedAt: msg.Time,
})
if err != nil {
log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
}
}
// LoadMessages loads persisted chat messages for a room, returning them
// in chronological order (oldest first).
func LoadMessages(queries *repository.Queries, roomID string) []Message {
rows, err := queries.GetChatMessages(context.Background(), roomID)
if err != nil {
return nil
}
msgs := make([]Message, len(rows))
for i, r := range rows {
msgs[i] = Message{
Nickname: r.Nickname,
Slot: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for chronological display
slices.Reverse(msgs)
return msgs
}