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