Phase 3: Polish & PWA

Backend:
- DB: reactions table, invites table, reply_to column on messages
- gateway/events.go: added REACTION_ADD, REACTION_REMOVE events
- internal/reaction/handlers.go: reaction CRUD with WebSocket broadcast
- internal/invite/handlers.go: invite creation, info, join with code
- gateway/hub.go: presence tracking with idle detection
- gateway/client.go: idle timeout support

Frontend - Social:
- TypingIndicator: real-time 'user is typing...' display
- ReactionBar: emoji reactions on messages with counts
- EmojiPicker: searchable emoji grid for reactions
- ReplyBar: quoted reply display above messages
- MentionPopup: @mention autocomplete with user list

Frontend - PWA:
- manifest.json: PWA manifest with theme color and icons
- sw.js: service worker with cache-first strategy and push support
- stores/push.ts: push notification subscription management
- InstallPrompt: 'Add to Home Screen' banner

Frontend - Mobile:
- MobileNav: bottom nav bar for mobile (servers/channels/chat/members)
- MobileDrawer: slide-out drawer with server bar + channel list
- index.html: PWA meta tags, safe area viewport

Frontend - Polish:
- ThemeToggle: dark/light mode switch with localStorage persistence
- InviteModal: generate invite links with expiry and max uses
- JoinServer: /invite/:code join flow
- stores/typing.ts: typing indicator state management
- stores/presence.ts: real-time presence tracking
- tailwind.config.js: darkMode: 'class', light mode color tokens
- styles/index.css: light mode CSS variable overrides
This commit is contained in:
2026-06-28 16:44:39 -04:00
parent ab35bdd1ae
commit bb650ac2a0
29 changed files with 1811 additions and 30 deletions
+247
View File
@@ -0,0 +1,247 @@
package reaction
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *sql.DB
hub *gateway.Hub
}
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
return &Handler{db: db, hub: hub}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/{messageID}/reactions", h.Add)
r.Delete("/{messageID}/reactions/{emoji}", h.Remove)
r.Get("/{messageID}/reactions", h.List)
}
type addReactionRequest struct {
Emoji string `json:"emoji"`
}
type reactionResponse struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
UserID string `json:"user_id"`
Emoji string `json:"emoji"`
CreatedAt string `json:"created_at"`
}
type emojiGroup struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
Users []string `json:"users"`
Details []reactionResponse `json:"details"`
}
// requireMessageAccess verifies the user is a member of the server that owns the message's channel.
// Returns (userID, channelID, serverID, ok).
func (h *Handler) requireMessageAccess(w http.ResponseWriter, r *http.Request, messageID string) (string, string, string, bool) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return "", "", "", false
}
// Look up channel_id from the message
var channelID string
err := h.db.QueryRowContext(r.Context(), `
SELECT channel_id FROM messages WHERE id = $1
`, messageID).Scan(&channelID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
} else {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
}
return "", "", "", false
}
// Look up server_id from the channel
var serverID string
err = h.db.QueryRowContext(r.Context(), `
SELECT server_id FROM channels WHERE id = $1
`, channelID).Scan(&serverID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
} else {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
}
return "", "", "", false
}
// Verify membership
var exists bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
`, userID, serverID).Scan(&exists)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return "", "", "", false
}
if !exists {
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
return "", "", "", false
}
return userID, channelID, serverID, true
}
func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
messageID := chi.URLParam(r, "messageID")
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
if !ok {
return
}
var req addReactionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Emoji == "" {
http.Error(w, `{"error":"emoji is required"}`, http.StatusBadRequest)
return
}
var reaction reactionResponse
var createdAt sql.NullString
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO 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 {
http.Error(w, `{"error":"failed to add reaction"}`, http.StatusInternalServerError)
return
}
reaction.CreatedAt = createdAt.String
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventReactionAdd,
Data: map[string]interface{}{
"reaction": reaction,
"channel_id": channelID,
"message_id": messageID,
},
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(reaction)
}
func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) {
messageID := chi.URLParam(r, "messageID")
emoji := chi.URLParam(r, "emoji")
userID, channelID, _, ok := h.requireMessageAccess(w, r, messageID)
if !ok {
return
}
result, err := h.db.ExecContext(r.Context(), `
DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3
`, messageID, userID, emoji)
if err != nil {
http.Error(w, `{"error":"failed to remove reaction"}`, http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
http.Error(w, `{"error":"reaction not found"}`, http.StatusNotFound)
return
}
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventReactionRemove,
Data: map[string]interface{}{
"user_id": userID,
"message_id": messageID,
"channel_id": channelID,
"emoji": emoji,
},
})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
messageID := chi.URLParam(r, "messageID")
if _, _, _, ok := h.requireMessageAccess(w, r, messageID); !ok {
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, message_id, user_id, emoji, created_at::text
FROM reactions
WHERE message_id = $1
ORDER BY created_at ASC
`, messageID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
// Group reactions by emoji
groupMap := make(map[string]*emojiGroup)
var order []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 {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
reaction.CreatedAt = createdAt.String
group, exists := groupMap[reaction.Emoji]
if !exists {
group = &emojiGroup{
Emoji: reaction.Emoji,
Users: []string{},
Details: []reactionResponse{},
}
groupMap[reaction.Emoji] = group
order = append(order, reaction.Emoji)
}
group.Count++
group.Users = append(group.Users, reaction.UserID)
group.Details = append(group.Details, reaction)
}
if err := rows.Err(); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
groups := make([]emojiGroup, 0, len(order))
for _, emoji := range order {
groups = append(groups, *groupMap[emoji])
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}