fix: DM message ordering, consolidate input toolbar, add rich text/WYSIWYG, file upload with drag-drop

- Fix DM backend ListMessages to use DESC + reverse (match channel handler)
- Remove spurious .reverse() from frontend message/conversation stores
- Create shared MessageInput component with Slack-style single toolbar row
- Add file upload via + button with progress bar and drag-and-drop
- Add markdown/rich text toggle with full WYSIWYG block formatting
  (lists, blockquotes, links, headings, code blocks)
- Add frontend+backend security for file uploads (extension + content-type guards)
This commit is contained in:
2026-07-06 17:33:20 +00:00
parent 9512dedec1
commit b3b5ff495d
19 changed files with 1369 additions and 301 deletions
+202 -4
View File
@@ -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)
}