diff --git a/dumpster b/dumpster index f0acb8b..f725d25 100755 Binary files a/dumpster and b/dumpster differ diff --git a/internal/db/db.go b/internal/db/db.go index b23f228..ef58a31 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -277,6 +277,15 @@ CREATE TABLE IF NOT EXISTS conversation_messages ( CREATE INDEX IF NOT EXISTS idx_conversation_members_user ON conversation_members(user_id); CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_created ON conversation_messages(conversation_id, created_at DESC); +CREATE TABLE IF NOT EXISTS conversation_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(message_id, user_id, emoji) +); + -- Moderation CREATE TABLE IF NOT EXISTS bans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/dm/handlers.go b/internal/dm/handlers.go index 610eded..6e172d0 100644 --- a/internal/dm/handlers.go +++ b/internal/dm/handlers.go @@ -5,10 +5,12 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "sort" "strconv" + "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" @@ -37,6 +39,10 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/", h.Get) r.Get("/messages", h.ListMessages) r.Post("/messages", h.SendMessage) + r.Route("/messages/{messageID}/reactions", func(r chi.Router) { + r.Post("/", h.AddReaction) + r.Delete("/{emoji}", h.RemoveReaction) + }) }) } @@ -275,8 +281,9 @@ type messageResponse struct { AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` Content string `json:"content"` - EditedAt *string `json:"edited_at"` - CreatedAt string `json:"created_at"` + EditedAt *string `json:"edited_at"` + CreatedAt string `json:"created_at"` + Reactions []emojiGroup `json:"reactions"` } // ListMessages lists messages in a conversation. @@ -309,7 +316,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { FROM conversation_messages m JOIN users u ON u.id = m.author_id WHERE m.conversation_id = $1 AND m.created_at < (SELECT created_at FROM conversation_messages WHERE id = $2) - ORDER BY m.created_at ASC + ORDER BY m.created_at DESC LIMIT $3 `, convID, before, limit) } else { @@ -318,7 +325,7 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { FROM conversation_messages m JOIN users u ON u.id = m.author_id WHERE m.conversation_id = $1 - ORDER BY m.created_at ASC + ORDER BY m.created_at DESC LIMIT $2 `, convID, limit) } @@ -347,6 +354,13 @@ func (h *Handler) ListMessages(w http.ResponseWriter, r *http.Request) { messages = append(messages, msg) } + messages = h.attachReactions(r.Context(), messages) + + // Reverse: query returns DESC (newest first), client expects ASC (oldest first). + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } @@ -403,6 +417,8 @@ func (h *Handler) SendMessage(w http.ResponseWriter, r *http.Request) { msg.DisplayName = &displayName.String } + msg = h.attachReactions(r.Context(), []messageResponse{msg})[0] + if h.hub != nil { h.hub.BroadcastToConversation(convID, gateway.Event{ Type: gateway.EventMessageCreate, @@ -457,3 +473,185 @@ func (h *Handler) MemberIDs(ctx context.Context, convID string) ([]string, error } return ids, nil } + +type emojiGroup struct { + Emoji string `json:"emoji"` + Count int `json:"count"` + Users []string `json:"users"` + Details []reactionResponse `json:"details"` +} + +func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse { + if len(messages) == 0 { + return messages + } + + msgIDs := make([]string, len(messages)) + msgMap := make(map[string]int) + for i, msg := range messages { + msgIDs[i] = msg.ID + msgMap[msg.ID] = i + messages[i].Reactions = make([]emojiGroup, 0) + } + + placeholders := make([]string, len(msgIDs)) + args := make([]interface{}, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + SELECT id, message_id, user_id, emoji, created_at::text + FROM conversation_reactions + WHERE message_id IN (%s) + ORDER BY created_at ASC + `, strings.Join(placeholders, ", ")) + + rows, err := h.db.QueryContext(ctx, query, args...) + if err != nil { + return messages + } + defer rows.Close() + + reactionsMap := make(map[string]map[string]*emojiGroup) + emojiOrderMap := make(map[string][]string) + + for rows.Next() { + var reaction reactionResponse + var createdAt sql.NullString + if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil { + continue + } + reaction.CreatedAt = createdAt.String + + msgID := reaction.MessageID + if _, ok := reactionsMap[msgID]; !ok { + reactionsMap[msgID] = make(map[string]*emojiGroup) + emojiOrderMap[msgID] = make([]string, 0) + } + + group, ok := reactionsMap[msgID][reaction.Emoji] + if !ok { + group = &emojiGroup{ + Emoji: reaction.Emoji, + Users: []string{}, + Details: []reactionResponse{}, + } + reactionsMap[msgID][reaction.Emoji] = group + emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji) + } + group.Count++ + group.Users = append(group.Users, reaction.UserID) + group.Details = append(group.Details, reaction) + } + + for msgID, emojisMap := range reactionsMap { + idx, ok := msgMap[msgID] + if !ok { + continue + } + order := emojiOrderMap[msgID] + for _, emoji := range order { + messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji]) + } + } + + return messages +} + +type reactionResponse struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + ConversationID string `json:"conversation_id"` + UserID string `json:"user_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +// @Summary Add a reaction to a DM +// @Router /conversations/{conversationID}/messages/{messageID}/reactions [post] +func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) { + convID := chi.URLParam(r, "conversationID") + messageID := chi.URLParam(r, "messageID") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok || !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Emoji == "" { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var reaction reactionResponse + reaction.ConversationID = convID + var createdAt sql.NullString + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO conversation_reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO UPDATE SET emoji = EXCLUDED.emoji + RETURNING id, message_id, user_id, emoji, created_at::text + `, messageID, userID, req.Emoji).Scan( + &reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt, + ) + if err != nil { + h.logger.Error("failed to add dm reaction", "error", err) + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + reaction.CreatedAt = createdAt.String + + h.hub.BroadcastToConversation(convID, gateway.Event{ + Type: gateway.EventReactionAdd, + Data: map[string]interface{}{ + "reaction": reaction, + "conversation_id": convID, + "message_id": messageID, + }, + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(reaction) +} + +// @Summary Remove a reaction from a DM +// @Router /conversations/{conversationID}/messages/{messageID}/reactions/{emoji} [delete] +func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) { + convID := chi.URLParam(r, "conversationID") + messageID := chi.URLParam(r, "messageID") + emoji := chi.URLParam(r, "emoji") + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok || !h.isMember(r.Context(), convID, userID) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + result, err := h.db.ExecContext(r.Context(), ` + DELETE FROM conversation_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3 + `, messageID, userID, emoji) + if err != nil { + h.logger.Error("failed to remove dm reaction", "error", err) + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected > 0 { + h.hub.BroadcastToConversation(convID, gateway.Event{ + Type: gateway.EventReactionRemove, + Data: map[string]interface{}{ + "conversation_id": convID, + "message_id": messageID, + "user_id": userID, + "emoji": emoji, + }, + }) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/package-lock.json b/web/package-lock.json index 1f32593..a52901c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,9 @@ "name": "dumpster-web", "version": "0.0.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.0", + "@fortawesome/free-solid-svg-icons": "^7.3.0", + "@fortawesome/react-fontawesome": "^3.3.1", "@livekit/components-react": "^2.9.21", "@livekit/track-processors": "^0.7.2", "emoji-picker-react": "^4.19.1", @@ -748,6 +751,52 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.0.tgz", + "integrity": "sha512-X/vND0Y1l9fVJ9O79UgtZnXSpz4aNF3bXlDxiJAEAm6kgeSftp9wjjBPgqzazJV8YlmxfRoeXNfSCJ48sf/Hhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.0.tgz", + "integrity": "sha512-MFbTNLDWkLJwbozDvHOZ7hwyDjQcBMBattlcOQ6ZmV5YD9bBrqdl1rNtmVjQ/lzqveXXX3sMz2Ew6fAgXoxmkw==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.0.tgz", + "integrity": "sha512-YxI/CuwWeI3nPIoYU//vkDS+3ige/67DPZ6XwMATpYEFESzO9L8zfJOKllGRgIlpT/uebrZCcvAzp3peD7GmTw==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.1.tgz", + "integrity": "sha512-wGnAPhfzivDwBWYmEG8MSrEXPruoiMMo48NnsRkj1NZkoaawgOijPNAiSHKMYEoCsqTBSgLTzL6EqTTWGaUR4w==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/web/package.json b/web/package.json index db7abd7..8b7d735 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.0", + "@fortawesome/free-solid-svg-icons": "^7.3.0", + "@fortawesome/react-fontawesome": "^3.3.1", "@livekit/components-react": "^2.9.21", "@livekit/track-processors": "^0.7.2", "emoji-picker-react": "^4.19.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 2f994c6..5a1da80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { DMChat } from './components/DMChat.tsx'; import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx'; import { ResetPasswordPage } from './components/ResetPasswordPage.tsx'; import { useAuthStore } from './stores/auth.ts'; +import { useWebSocketStore } from './stores/ws.ts'; import { InstallBanner } from './components/InstallBanner.tsx'; import { ConnectionStatus } from './components/ConnectionStatus.tsx'; @@ -22,12 +23,20 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function App() { const fetchMe = useAuthStore((state) => state.fetchMe); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const [init, setInit] = useState(false); useEffect(() => { fetchMe().finally(() => setInit(true)); }, [fetchMe]); + // Connect WebSocket for real-time messages once authenticated + useEffect(() => { + if (isAuthenticated) { + useWebSocketStore.getState().connect(); + } + }, [isAuthenticated]); + if (!init) { return