refactor: extract standalone chat package from game-specific handlers
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:
@@ -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 }:
|
||||
</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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user