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"`
-}