From 10de5d21ad6609f86a309dda3460fbd657d5b1dc Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:20:21 -1000 Subject: [PATCH] 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. --- chat/chat.go | 92 ++++++++++++++ chat/components/chat.templ | 74 +++++++++++ chat/persist.go | 45 +++++++ features/c4game/components/chat.templ | 69 ---------- features/c4game/handlers.go | 152 +++++++---------------- features/c4game/pages/game.templ | 6 +- features/snakegame/components/chat.templ | 66 ---------- features/snakegame/handlers.go | 72 ++++++----- features/snakegame/pages/game.templ | 8 +- game/types.go | 8 -- 10 files changed, 305 insertions(+), 287 deletions(-) create mode 100644 chat/chat.go create mode 100644 chat/components/chat.templ create mode 100644 chat/persist.go delete mode 100644 features/c4game/components/chat.templ delete mode 100644 features/snakegame/components/chat.templ diff --git a/chat/chat.go b/chat/chat.go new file mode 100644 index 0000000..632f919 --- /dev/null +++ b/chat/chat.go @@ -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 +} diff --git a/chat/components/chat.templ b/chat/components/chat.templ new file mode 100644 index 0000000..2a2199e --- /dev/null +++ b/chat/components/chat.templ @@ -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) { +
+
+ for _, m := range messages { +
+ + { m.Nickname + ": " } + + { m.Message } +
+ } +
+
+ if cfg.StopKeyPropagation { + + } else { + + } + +
+ @chatAutoScroll(cfg.CSSPrefix) +
+} + +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}); +} diff --git a/chat/persist.go b/chat/persist.go new file mode 100644 index 0000000..0297b42 --- /dev/null +++ b/chat/persist.go @@ -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 +} diff --git a/features/c4game/components/chat.templ b/features/c4game/components/chat.templ deleted file mode 100644 index c1e6c07..0000000 --- a/features/c4game/components/chat.templ +++ /dev/null @@ -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) { -
-
- for _, m := range messages { -
- - { m.Nickname }:  - - { m.Message } -
- } - @chatAutoScroll() -
-
- - -
-
-} - -templ chatAutoScroll() { - -} - -func chatColor(color int) string { - if c, ok := chatColors[color]; ok { - return c - } - return "#666" -} diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index c47551b..906541a 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -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 } diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index eb328ad..eee6222 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -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") {
@components.Board(g, myColor) - @components.Chat(messages, g.ID) + @chatcomponents.Chat(messages, chatCfg) if g.Status == game.StatusWaitingForPlayer { @components.InviteLink(g.ID) diff --git a/features/snakegame/components/chat.templ b/features/snakegame/components/chat.templ deleted file mode 100644 index 58c137b..0000000 --- a/features/snakegame/components/chat.templ +++ /dev/null @@ -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) { -
-
- for _, m := range messages { -
- - { m.Nickname + ": " } - - { m.Message } -
- } -
-
- - -
- @chatAutoScroll() -
-} - -templ chatAutoScroll() { - -} - -func chatColor(slot int) string { - if slot >= 0 && slot < len(snake.SnakeColors) { - return snake.SnakeColors[slot] - } - return "#666" -} diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index b72fcc1..13dde02 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -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) diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 49b90de..46fe32d 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -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") {
@snakecomponents.Board(sg) - @snakecomponents.Chat(messages, gameID) + @chatcomponents.Chat(messages, chatCfg) } 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) diff --git a/game/types.go b/game/types.go index 2eec2b1..2ceef46 100644 --- a/game/types.go +++ b/game/types.go @@ -69,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error { } return json.Unmarshal([]byte(data), &g.WinningCells) } - -// ChatMessage is the domain type for persisted C4 chat messages. -type ChatMessage struct { - Nickname string `json:"nickname"` - Color int `json:"color"` // 1=Red, 2=Yellow - Message string `json:"message"` - Time int64 `json:"time"` -}