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

View File

@@ -1,69 +0,0 @@
package components
import (
"fmt"
"github.com/starfederation/datastar-go/datastar"
)
type ChatMessage struct {
Nickname string `json:"nickname"`
Color int `json:"color"`
Message string `json:"message"`
Time int64 `json:"time"`
}
var chatColors = map[int]string{
1: "#4a2a3a",
2: "#2a4545",
}
templ Chat(messages []ChatMessage, gameID string) {
<div id="c4-chat" class="c4-chat">
<div class="c4-chat-history">
for _, m := range messages {
<div class="c4-chat-msg">
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Color)) }>
{ m.Nickname }:&nbsp;
</span>
<span>{ m.Message }</span>
</div>
}
@chatAutoScroll()
</div>
<div class="c4-chat-input" data-morph-ignore>
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.enter={ datastar.PostSSE("/games/%s/chat", gameID) }
/>
<button
type="button"
data-on:click={ datastar.PostSSE("/games/%s/chat", gameID) }
>
Send
</button>
</div>
</div>
}
templ chatAutoScroll() {
<script>
(function(){
var el = document.querySelector('.c4-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
</script>
}
func chatColor(color int) string {
if c, ok := chatColors[color]; ok {
return c
}
return "#666"
}

View File

@@ -1,12 +1,9 @@
package c4game
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"strconv"
"sync"
"time"
"github.com/alexedwards/scs/v2"
@@ -14,6 +11,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
@@ -21,6 +20,27 @@ import (
"github.com/ryanhamamura/c4/sessions"
)
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
var c4ChatColors = map[int]string{
0: "#4a2a3a", // color 1 stored as slot 0
1: "#2a4545", // color 2 stored as slot 1
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
func c4ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -53,25 +73,21 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo
// Player not in game
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Player is in the game — render full game page
g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID)
msgs := chatToComponents(chatMsgs)
msgs := chat.LoadMessages(queries, gameID)
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -94,13 +110,11 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Load initial chat messages
chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
chatCfg := c4ChatConfig(gameID)
room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
// Send initial render of all components
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
// Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
@@ -111,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64)
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
@@ -124,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag
case <-ctx.Done():
return
case <-gameCh:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
sendGameComponents(sse, gi, myColor, room, chatCfg)
case msg := <-chatCh:
var uiMsg game.ChatMessage
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
_, snapshot := room.Receive(msg.Data)
if snapshot == nil {
continue
}
cm := components.ChatMessage{
Nickname: uiMsg.Nickname,
Color: uiMsg.Color,
Message: uiMsg.Message,
Time: uiMsg.Time,
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
return
}
}
@@ -180,9 +177,6 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler
}
gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r)
}
}
@@ -227,22 +221,18 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager
}
}
cm := game.ChatMessage{
// Map color (1-based) to slot (0-based) for the unified chat message
msg := chat.Message{
Nickname: nick,
Color: myColor,
Slot: myColor - 1,
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
saveChatMessage(queries, gameID, cm)
chat.SaveMessage(queries, gameID, msg)
data, err := json.Marshal(cm)
if err != nil {
datastar.NewSSE(w, r)
return
}
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
room := chat.NewRoom(nc, "game.chat."+gameID, nil)
room.Send(msg)
// Clear the chat input
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
@@ -314,61 +304,11 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu
}
// sendGameComponents patches all game-related SSE components.
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) {
g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
chatMu.Lock()
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}
// Chat persistence helpers — inlined from the former ChatPersister.
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),
Message: msg.Message,
CreatedAt: msg.Time,
})
}
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
rows, err := queries.GetChatMessages(context.Background(), gameID)
if err != nil {
return nil
}
msgs := make([]game.ChatMessage, len(rows))
for i, r := range rows {
msgs[i] = game.ChatMessage{
Nickname: r.Nickname,
Color: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for display
slices.Reverse(msgs)
return msgs
}
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
msgs := make([]components.ChatMessage, len(chatMsgs))
for i, m := range chatMsgs {
msgs[i] = components.ChatMessage{
Nickname: m.Nickname,
Color: m.Color,
Message: m.Message,
Time: m.Time,
}
}
return msgs
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}

View File

@@ -1,6 +1,8 @@
package pages
import (
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/c4game/components"
sharedcomponents "github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
@@ -8,7 +10,7 @@ import (
"github.com/starfederation/datastar-go/datastar"
)
templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
@layouts.Base("Connect 4") {
<main
class="flex flex-col items-center gap-4 p-4"
@@ -21,7 +23,7 @@ templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) {
@components.StatusBanner(g, myColor)
<div class="c4-game-area">
@components.Board(g, myColor)
@components.Chat(messages, g.ID)
@chatcomponents.Chat(messages, chatCfg)
</div>
if g.Status == game.StatusWaitingForPlayer {
@components.InviteLink(g.ID)

View File

@@ -1,66 +0,0 @@
package components
import (
"fmt"
"github.com/ryanhamamura/c4/snake"
"github.com/starfederation/datastar-go/datastar"
)
type ChatMessage struct {
Nickname string `json:"nickname"`
Slot int `json:"slot"`
Message string `json:"message"`
Time int64 `json:"time"`
}
templ Chat(messages []ChatMessage, gameID string) {
<div id="snake-chat" class="snake-chat">
<div class="snake-chat-history">
for _, m := range messages {
<div class="snake-chat-msg">
<span style={ fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Slot)) }>
{ m.Nickname + ": " }
</span>
<span>{ m.Message }</span>
</div>
}
</div>
<div class="snake-chat-input" data-morph-ignore>
<input
type="text"
placeholder="Chat..."
autocomplete="off"
data-bind="chatMsg"
data-on:keydown.stop=""
data-on:keydown.key_enter={ datastar.PostSSE("/snake/%s/chat", gameID) }
/>
<button
type="button"
data-on:click={ datastar.PostSSE("/snake/%s/chat", gameID) }
>
Send
</button>
</div>
@chatAutoScroll()
</div>
}
templ chatAutoScroll() {
<script>
(function(){
var el = document.querySelector('.snake-chat-history');
if (!el) return;
el.scrollTop = el.scrollHeight;
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
.observe(el, {childList:true, subtree:true});
})();
</script>
}
func chatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}

View File

@@ -1,22 +1,39 @@
package snakegame
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
)
func snakeChatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
// Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
mySlot := si.GetPlayerSlot(playerID)
if mySlot < 0 {
// Not in game yet
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
@@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
}
sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
chatCfg := snakeChatConfig(gameID)
// Send initial render
sg := si.GetGame()
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
if sg.Mode == snake.ModeMultiplayer {
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
@@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
var chatMessages []components.ChatMessage
var chatMu sync.Mutex
var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
chatCh = make(chan *nats.Msg, 64)
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
@@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
if msg == nil {
continue
}
var cm components.ChatMessage
if err := json.Unmarshal(msg.Data, &cm); err != nil {
_, snapshot := room.Receive(msg.Data)
if snapshot == nil {
continue
}
chatMu.Lock()
chatMessages = append(chatMessages, cm)
if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:]
}
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil {
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
return
}
}
@@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
}
sg := si.GetGame()
cm := components.ChatMessage{
msg := chat.Message{
Nickname: sg.Players[slot].Nickname,
Slot: slot,
Message: signals.ChatMsg,
}
data, err := json.Marshal(cm)
if err != nil {
return
}
nc.Publish("snake.chat."+gameID, data) //nolint:errcheck
room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
room.Send(msg)
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
sse := datastar.NewSSE(w, r)

View File

@@ -3,6 +3,8 @@ package pages
import (
"fmt"
"github.com/ryanhamamura/c4/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/common/components"
"github.com/ryanhamamura/c4/features/common/layouts"
snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components"
@@ -26,7 +28,7 @@ func keydownScript(gameID string) string {
)
}
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) {
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
@layouts.Base("Snake") {
<main
class="snake-wrapper flex flex-col items-center gap-4 p-4"
@@ -43,13 +45,13 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatM
if sg.Mode == snake.ModeMultiplayer {
<div class="snake-game-area">
@snakecomponents.Board(sg)
@snakecomponents.Chat(messages, gameID)
@chatcomponents.Chat(messages, chatCfg)
</div>
} else {
@snakecomponents.Board(sg)
}
} else if sg.Mode == snake.ModeMultiplayer {
@snakecomponents.Chat(messages, gameID)
@chatcomponents.Chat(messages, chatCfg)
}
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
@snakecomponents.InviteLink(gameID)