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:
+202
-4
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user