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:
@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
edited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -112,4 +113,28 @@ CREATE TABLE IF NOT EXISTS member_roles (
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES 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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(16) NOT NULL UNIQUE,
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_uses INTEGER,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
|
||||
`
|
||||
|
||||
@@ -61,6 +61,9 @@ func (c *Client) readPump() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Record activity for idle detection.
|
||||
c.Hub.RecordActivity(c.UserID)
|
||||
|
||||
// Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE)
|
||||
// For now, broadcast them to all clients
|
||||
switch event.Type {
|
||||
|
||||
@@ -14,6 +14,8 @@ const (
|
||||
EventMemberRemove = "SERVER_MEMBER_REMOVE"
|
||||
EventPresenceUpdate = "PRESENCE_UPDATE"
|
||||
EventTypingStart = "TYPING_START"
|
||||
EventReactionAdd = "REACTION_ADD"
|
||||
EventReactionRemove = "REACTION_REMOVE"
|
||||
EventVoiceJoin = "VOICE_JOIN"
|
||||
EventVoiceLeave = "VOICE_LEAVE"
|
||||
EventVoiceMute = "VOICE_MUTE"
|
||||
|
||||
+131
-12
@@ -1,40 +1,70 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// idleTimeout is the duration of inactivity before a user is set to idle.
|
||||
idleTimeout = 5 * time.Minute
|
||||
// idleCheckInterval is how often the hub checks for idle users.
|
||||
idleCheckInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// PresenceData is the data payload for PRESENCE_UPDATE events.
|
||||
type PresenceData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages to them.
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
presence map[string]string // userID -> status
|
||||
lastActivity map[string]time.Time // userID -> last activity time
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
broadcast chan []byte
|
||||
db *sql.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub.
|
||||
func NewHub(logger *slog.Logger) *Hub {
|
||||
func NewHub(db *sql.DB, logger *slog.Logger) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]struct{}),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 256),
|
||||
logger: logger,
|
||||
clients: make(map[*Client]struct{}),
|
||||
presence: make(map[string]string),
|
||||
lastActivity: make(map[string]time.Time),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
broadcast: make(chan []byte, 256),
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the hub's main event loop. Call this in a goroutine.
|
||||
func (h *Hub) Run() {
|
||||
idleTicker := time.NewTicker(idleCheckInterval)
|
||||
defer idleTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = struct{}{}
|
||||
h.presence[client.UserID] = "online"
|
||||
h.lastActivity[client.UserID] = time.Now()
|
||||
h.mu.Unlock()
|
||||
|
||||
h.setUserStatus(client.UserID, "online")
|
||||
h.broadcastPresence(client.UserID, "online")
|
||||
h.logger.Info("client connected", "user_id", client.UserID)
|
||||
|
||||
case client := <-h.unregister:
|
||||
@@ -43,7 +73,19 @@ func (h *Hub) Run() {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
|
||||
// Check if user has any remaining connected clients.
|
||||
hasOther := h.hasClientForUser(client.UserID)
|
||||
if !hasOther {
|
||||
delete(h.presence, client.UserID)
|
||||
delete(h.lastActivity, client.UserID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !hasOther {
|
||||
h.setUserStatus(client.UserID, "offline")
|
||||
h.broadcastPresence(client.UserID, "offline")
|
||||
}
|
||||
h.logger.Info("client disconnected", "user_id", client.UserID)
|
||||
|
||||
case message := <-h.broadcast:
|
||||
@@ -58,10 +100,87 @@ func (h *Hub) Run() {
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
case <-idleTicker.C:
|
||||
h.checkIdleUsers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasClientForUser returns true if any connected client belongs to the given
|
||||
// user. Caller must hold h.mu (at least for reading).
|
||||
func (h *Hub) hasClientForUser(userID string) bool {
|
||||
for c := range h.clients {
|
||||
if c.UserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecordActivity updates the last-activity timestamp for a user. If the user
|
||||
// was idle, it resets their status back to online and broadcasts the change.
|
||||
// Safe to call from any goroutine.
|
||||
func (h *Hub) RecordActivity(userID string) {
|
||||
h.mu.Lock()
|
||||
h.lastActivity[userID] = time.Now()
|
||||
wasIdle := h.presence[userID] == "idle"
|
||||
if wasIdle {
|
||||
h.presence[userID] = "online"
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if wasIdle {
|
||||
h.setUserStatus(userID, "online")
|
||||
h.broadcastPresence(userID, "online")
|
||||
}
|
||||
}
|
||||
|
||||
// checkIdleUsers scans all tracked users and marks as idle any who have been
|
||||
// inactive for longer than idleTimeout.
|
||||
func (h *Hub) checkIdleUsers() {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
var idleUsers []string
|
||||
for userID, last := range h.lastActivity {
|
||||
if h.presence[userID] == "online" && now.Sub(last) >= idleTimeout {
|
||||
h.presence[userID] = "idle"
|
||||
idleUsers = append(idleUsers, userID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, userID := range idleUsers {
|
||||
h.setUserStatus(userID, "idle")
|
||||
h.broadcastPresence(userID, "idle")
|
||||
}
|
||||
}
|
||||
|
||||
// setUserStatus persists the user's status to the database.
|
||||
func (h *Hub) setUserStatus(userID, status string) {
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := h.db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastPresence sends a PRESENCE_UPDATE event to all connected clients.
|
||||
func (h *Hub) broadcastPresence(userID, status string) {
|
||||
h.BroadcastEvent(Event{
|
||||
Type: EventPresenceUpdate,
|
||||
Data: PresenceData{
|
||||
UserID: userID,
|
||||
Status: status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Register queues a client for registration with the hub.
|
||||
func (h *Hub) Register(client *Client) {
|
||||
h.register <- client
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const codeLength = 8
|
||||
const codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/servers/{serverID}/invites", h.Create)
|
||||
r.Get("/invites/{code}", h.Get)
|
||||
r.Post("/invites/{code}/join", h.Join)
|
||||
}
|
||||
|
||||
type createInviteRequest struct {
|
||||
ExpiresHours *int `json:"expires_hours"`
|
||||
MaxUses *int `json:"max_uses"`
|
||||
}
|
||||
|
||||
type inviteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
ServerID string `json:"server_id"`
|
||||
ServerName string `json:"server_name"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
MaxUses *int `json:"max_uses"`
|
||||
UseCount int `json:"use_count"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// generateCode generates a cryptographically random alphanumeric code of the given length.
|
||||
func generateCode(length int) (string, error) {
|
||||
result := make([]byte, length)
|
||||
max := big.NewInt(int64(len(codeChars)))
|
||||
for i := 0; i < length; i++ {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[i] = codeChars[n.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := chi.URLParam(r, "serverID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user is a member of the server
|
||||
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
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req createInviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Allow empty body
|
||||
req = createInviteRequest{}
|
||||
}
|
||||
|
||||
// Generate a unique code (retry on collision)
|
||||
var code string
|
||||
for i := 0; i < 5; i++ {
|
||||
code, err = generateCode(codeLength)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to generate invite code"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
var dup bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM invites WHERE code = $1)
|
||||
`, code).Scan(&dup)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !dup {
|
||||
break
|
||||
}
|
||||
if i == 4 {
|
||||
http.Error(w, `{"error":"failed to generate unique code"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Compute expiration
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresHours != nil && *req.ExpiresHours > 0 {
|
||||
t := time.Now().Add(time.Duration(*req.ExpiresHours) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
var maxUses *int
|
||||
if req.MaxUses != nil && *req.MaxUses > 0 {
|
||||
maxUses = req.MaxUses
|
||||
}
|
||||
|
||||
var invite inviteResponse
|
||||
var expiresAtStr sql.NullString
|
||||
var createdAtStr sql.NullString
|
||||
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO invites (code, server_id, creator_id, max_uses, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, code, server_id, creator_id, max_uses, use_count, expires_at::text, created_at::text
|
||||
`, code, serverID, userID, maxUses, expiresAt).Scan(
|
||||
&invite.ID, &invite.Code, &invite.ServerID, &invite.CreatorID,
|
||||
&invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create invite"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if expiresAtStr.Valid {
|
||||
invite.ExpiresAt = &expiresAtStr.String
|
||||
}
|
||||
invite.CreatedAt = createdAtStr.String
|
||||
|
||||
// Fetch server name
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT name FROM servers WHERE id = $1
|
||||
`, serverID).Scan(&invite.ServerName)
|
||||
if err != nil {
|
||||
// Non-fatal: just leave server_name empty
|
||||
invite.ServerName = ""
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(invite)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
|
||||
var invite inviteResponse
|
||||
var expiresAtStr sql.NullString
|
||||
var createdAtStr sql.NullString
|
||||
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT i.id, i.code, i.server_id, COALESCE(s.name, ''), i.creator_id,
|
||||
i.max_uses, i.use_count, i.expires_at::text, i.created_at::text
|
||||
FROM invites i
|
||||
LEFT JOIN servers s ON s.id = i.server_id
|
||||
WHERE i.code = $1
|
||||
`, code).Scan(
|
||||
&invite.ID, &invite.Code, &invite.ServerID, &invite.ServerName, &invite.CreatorID,
|
||||
&invite.MaxUses, &invite.UseCount, &expiresAtStr, &createdAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if expiresAtStr.Valid {
|
||||
invite.ExpiresAt = &expiresAtStr.String
|
||||
}
|
||||
invite.CreatedAt = createdAtStr.String
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(invite)
|
||||
}
|
||||
|
||||
func (h *Handler) Join(w http.ResponseWriter, r *http.Request) {
|
||||
code := chi.URLParam(r, "code")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch the invite
|
||||
var inviteID, serverID string
|
||||
var maxUses *int
|
||||
var useCount int
|
||||
var expiresAt sql.NullTime
|
||||
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT id, server_id, max_uses, use_count, expires_at
|
||||
FROM invites WHERE code = $1
|
||||
`, code).Scan(&inviteID, &serverID, &maxUses, &useCount, &expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"invite not found"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if expiresAt.Valid && time.Now().After(expiresAt.Time) {
|
||||
http.Error(w, `{"error":"invite has expired"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check max uses
|
||||
if maxUses != nil && useCount >= *maxUses {
|
||||
http.Error(w, `{"error":"invite has reached maximum uses"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
var alreadyMember bool
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||
`, userID, serverID).Scan(&alreadyMember)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if alreadyMember {
|
||||
http.Error(w, `{"error":"already a member of this server"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a transaction to insert member and increment use_count atomically
|
||||
tx, err := h.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO members (user_id, server_id) VALUES ($1, $2)
|
||||
`, userID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to join server"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
UPDATE invites SET use_count = use_count + 1 WHERE id = $1
|
||||
`, inviteID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to update invite"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"server_id": serverID,
|
||||
"message": "successfully joined server",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user