diff --git a/cmd/server/main.go b/cmd/server/main.go index cfc620c..80dcc25 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -43,7 +43,7 @@ func main() { sessionStore := auth.NewSessionStore(database.DB, cfg) // WebSocket hub - hub := gateway.NewHub(logger) + hub := gateway.NewHub(database.DB, logger) go hub.Run() // Giphy client (nil if no API key) diff --git a/internal/db/db.go b/internal/db/db.go index c4c3517..b2e98d2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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); ` diff --git a/internal/gateway/client.go b/internal/gateway/client.go index ec19990..cac4301 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -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 { diff --git a/internal/gateway/events.go b/internal/gateway/events.go index 8ac0ee7..db70e10 100644 --- a/internal/gateway/events.go +++ b/internal/gateway/events.go @@ -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" diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 50651e0..3b86d2e 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -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 diff --git a/internal/invite/handlers.go b/internal/invite/handlers.go new file mode 100644 index 0000000..49b35d3 --- /dev/null +++ b/internal/invite/handlers.go @@ -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", + }) +} diff --git a/internal/reaction/handlers.go b/internal/reaction/handlers.go new file mode 100644 index 0000000..40d4fdd --- /dev/null +++ b/internal/reaction/handlers.go @@ -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) +} diff --git a/web/index.html b/web/index.html index 5e3836a..7f7423d 100644 --- a/web/index.html +++ b/web/index.html @@ -3,11 +3,25 @@ - - web + + + + + + + + Dumpster
+ diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..2d8e857 --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Dumpster", + "short_name": "Dumpster", + "description": "A chaotic, self-hosted Discord-like platform", + "start_url": "/", + "display": "standalone", + "background_color": "#282828", + "theme_color": "#282828", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..6dd568d --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,96 @@ +const CACHE_NAME = 'dumpster-v1'; +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip API requests + if (event.request.url.includes('/api/')) return; + + // Skip WebSocket + if (event.request.url.includes('/ws')) return; + + event.respondWith( + caches.match(event.request).then((cached) => { + // Return cached version, fetch new one in background + const fetchPromise = fetch(event.request).then((response) => { + // Only cache successful responses + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, clone); + }); + } + return response; + }).catch(() => { + // Return cached if fetch fails + return cached; + }); + + return cached || fetchPromise; + }) + ); +}); + +self.addEventListener('push', (event) => { + if (!event.data) return; + + const data = event.data.json(); + const options = { + body: data.body, + icon: '/icons/icon-192.png', + badge: '/icons/badge.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/', + }, + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'Dumpster', options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // Focus existing window if available + for (const client of clientList) { + if (client.url.includes(event.notification.data.url) && 'focus' in client) { + return client.focus(); + } + } + // Otherwise open new window + return clients.openWindow(event.notification.data.url); + }) + ); +}); diff --git a/web/src/components/EmojiPicker.tsx b/web/src/components/EmojiPicker.tsx new file mode 100644 index 0000000..ff32315 --- /dev/null +++ b/web/src/components/EmojiPicker.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +const EMOJI_LIST = [ + '👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥', + '👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎', + '🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃', + '🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵', +]; + +interface EmojiPickerProps { + onSelect: (emoji: string) => void; + onClose: () => void; +} + +export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { + const [search, setSearch] = useState(''); + + const filtered = search + ? EMOJI_LIST.filter(e => e.includes(search)) + : EMOJI_LIST; + + return ( +
+
+ EMOJI + +
+ setSearch(e.target.value)} + placeholder="search..." + className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none" + autoFocus + /> +
+ {filtered.map((emoji) => ( + + ))} +
+ {filtered.length === 0 && ( +
[no results]
+ )} +
+ ); +} diff --git a/web/src/components/InstallPrompt.tsx b/web/src/components/InstallPrompt.tsx new file mode 100644 index 0000000..65a0304 --- /dev/null +++ b/web/src/components/InstallPrompt.tsx @@ -0,0 +1,78 @@ +import { useState, useEffect } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +export function InstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [showInstall, setShowInstall] = useState(false); + const [installed, setInstalled] = useState(false); + + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + setDeferredPrompt(e as BeforeInstallPromptEvent); + setShowInstall(true); + }; + + window.addEventListener('beforeinstallprompt', handler); + + window.addEventListener('appinstalled', () => { + setInstalled(true); + setShowInstall(false); + setDeferredPrompt(null); + }); + + return () => { + window.removeEventListener('beforeinstallprompt', handler); + }; + }, []); + + const handleInstall = async () => { + if (!deferredPrompt) return; + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + if (outcome === 'accepted') { + setInstalled(true); + setShowInstall(false); + } + setDeferredPrompt(null); + }; + + if (!showInstall || installed) return null; + + return ( +
+
+
+ INSTALL DUMPSTER + +
+

+ Add to your home screen for the full experience. Push notifications, offline access, and more. +

+
+ + +
+
+
+ ); +} diff --git a/web/src/components/InviteModal.tsx b/web/src/components/InviteModal.tsx new file mode 100644 index 0000000..ebb17fd --- /dev/null +++ b/web/src/components/InviteModal.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { api } from '../lib/api.ts'; + +interface InviteModalProps { + serverId: string; + serverName: string; + onClose: () => void; +} + +interface Invite { + code: string; + url: string; + expires_at: string | null; + max_uses: number | null; +} + +export function InviteModal({ serverId, serverName, onClose }: InviteModalProps) { + const [expiresHours, setExpiresHours] = useState(24); + const [maxUses, setMaxUses] = useState(''); + const [invite, setInvite] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const createInvite = async () => { + setLoading(true); + setError(null); + try { + const payload: Record = { + expires_hours: expiresHours, + }; + if (maxUses !== '') { + payload.max_uses = maxUses; + } + const result = await api.post(`/servers/${serverId}/invites`, payload); + setInvite(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create invite'); + } finally { + setLoading(false); + } + }; + + const copyLink = () => { + if (invite) { + navigator.clipboard.writeText(invite.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+
e.stopPropagation()}> +
+ INVITE TO {serverName.toUpperCase()} + +
+ + {!invite ? ( + <> +
+
+ + +
+
+ + setMaxUses(e.target.value === '' ? '' : Number(e.target.value))} + placeholder="unlimited" + min={1} + className="w-full px-2 py-1 bg-gb-bg-s text-gb-fg text-xs border-none outline-none" + /> +
+ {error && ( +
{error}
+ )} + +
+ + ) : ( +
+
Share this link:
+
+ + +
+ {invite.expires_at && ( +
+ Expires: {new Date(invite.expires_at).toLocaleString()} +
+ )} + {invite.max_uses && ( +
+ Max uses: {invite.max_uses} +
+ )} + +
+ )} +
+
+ ); +} diff --git a/web/src/components/JoinServer.tsx b/web/src/components/JoinServer.tsx new file mode 100644 index 0000000..715d88b --- /dev/null +++ b/web/src/components/JoinServer.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { api } from '../lib/api.ts'; + +interface InviteInfo { + server_name: string; + inviter: string; + expires_at: string | null; +} + +export function JoinServer() { + const { code } = useParams<{ code: string }>(); + const navigate = useNavigate(); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!code) return; + const fetchInfo = async () => { + try { + const data = await api.get(`/invites/${code}`); + setInfo(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid invite'); + } finally { + setLoading(false); + } + }; + fetchInfo(); + }, [code]); + + const joinServer = async () => { + if (!code) return; + setJoining(true); + setError(null); + try { + const result = await api.post<{ server_id: string }>(`/invites/${code}/join`); + navigate(`/channels/${result.server_id}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to join'); + } finally { + setJoining(false); + } + }; + + if (loading) { + return ( +
+
Loading invite...
+
+ ); + } + + if (error) { + return ( +
+
+
INVALID INVITE
+
{error}
+ +
+
+ ); + } + + return ( +
+
+
+
YOU'VE BEEN INVITED
+
{info?.server_name}
+ {info?.inviter && ( +
by {info.inviter}
+ )} +
+ {info?.expires_at && ( +
+ Expires: {new Date(info.expires_at).toLocaleString()} +
+ )} + {error && ( +
{error}
+ )} + +
+
+ ); +} diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx index 83bbf72..e6c7b8a 100644 --- a/web/src/components/MemberList.tsx +++ b/web/src/components/MemberList.tsx @@ -1,3 +1,4 @@ +import { usePresenceStore } from '../stores/presence.ts'; import type { User, UserStatus } from '../stores/auth.ts'; interface MemberListProps { @@ -43,9 +44,41 @@ function usernameColor(status: UserStatus): string { } } +/** Return the live status for a member, falling back to the static value. */ +function useLiveStatus(member: User): UserStatus { + const presence = usePresenceStore((s) => s.presences[member.id]); + return presence?.status ?? member.status; +} + +interface MemberRowProps { + member: User; +} + +function MemberRow({ member }: MemberRowProps) { + const status = useLiveStatus(member); + + return ( +
+ {statusIcon(status)} + + {member.username} + +
+ ); +} + export function MemberList({ members = [] }: MemberListProps) { - const online = members.filter((m) => m.status !== 'offline'); - const offline = members.filter((m) => m.status === 'offline'); + // Derive live status for every member to decide online/offline buckets. + const presences = usePresenceStore((s) => s.presences); + + const online = members.filter((m) => { + const status = presences[m.id]?.status ?? m.status; + return status !== 'offline'; + }); + const offline = members.filter((m) => { + const status = presences[m.id]?.status ?? m.status; + return status === 'offline'; + }); return (
@@ -61,12 +94,7 @@ export function MemberList({ members = [] }: MemberListProps) {
ONLINE
---
{online.map((member) => ( -
- {statusIcon(member.status)} - - {member.username} - -
+ ))}
)} @@ -75,12 +103,7 @@ export function MemberList({ members = [] }: MemberListProps) {
OFFLINE
---
{offline.map((member) => ( -
- {statusIcon(member.status)} - - {member.username} - -
+ ))} )} diff --git a/web/src/components/MentionPopup.tsx b/web/src/components/MentionPopup.tsx new file mode 100644 index 0000000..1cefdfb --- /dev/null +++ b/web/src/components/MentionPopup.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect, useRef } from 'react'; + +interface User { + id: string; + username: string; + display_name: string; +} + +interface MentionPopupProps { + users: User[]; + filter: string; + onSelect: (user: User) => void; + position: { top: number; left: number }; +} + +export function MentionPopup({ users, filter, onSelect, position }: MentionPopupProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + const filtered = users.filter(u => + u.username.toLowerCase().includes(filter.toLowerCase()) || + u.display_name?.toLowerCase().includes(filter.toLowerCase()) + ).slice(0, 8); + + useEffect(() => { + setSelectedIndex(0); + }, [filter]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (filtered[selectedIndex]) { + onSelect(filtered[selectedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + onSelect(null as any); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [filtered, selectedIndex, onSelect]); + + if (filtered.length === 0) return null; + + return ( +
+
+ MEMBERS +
+ {filtered.map((user, index) => ( + + ))} +
+ ); +} diff --git a/web/src/components/MobileDrawer.tsx b/web/src/components/MobileDrawer.tsx new file mode 100644 index 0000000..7ec4074 --- /dev/null +++ b/web/src/components/MobileDrawer.tsx @@ -0,0 +1,34 @@ +import { ServerBar } from './ServerBar.tsx'; +import { ChannelList } from './ChannelList.tsx'; + +interface MobileDrawerProps { + isOpen: boolean; + onClose: () => void; +} + +export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) { + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ {/* Drawer */} +
+ {/* Server bar */} +
+ +
+ {/* Channel list */} +
+ +
+
+ {/* Swipe hint */} +
+
+ ); +} diff --git a/web/src/components/MobileNav.tsx b/web/src/components/MobileNav.tsx new file mode 100644 index 0000000..a0c8ba3 --- /dev/null +++ b/web/src/components/MobileNav.tsx @@ -0,0 +1,53 @@ +import { useVoiceStore } from '../stores/voice.ts'; +import { ThemeToggle } from './ThemeToggle.tsx'; + +interface MobileNavProps { + activeTab: 'servers' | 'channels' | 'chat' | 'members'; + onTabChange: (tab: 'servers' | 'channels' | 'chat' | 'members') => void; + onToggleDrawer: () => void; +} + +export function MobileNav({ activeTab, onTabChange, onToggleDrawer }: MobileNavProps) { + const isConnected = useVoiceStore((state: any) => state.isConnected); + + const tabs = [ + { id: 'servers' as const, label: '[=]', title: 'Servers' }, + { id: 'channels' as const, label: '[#]', title: 'Channels' }, + { id: 'chat' as const, label: '[_]', title: 'Chat' }, + { id: 'members' as const, label: '[@]', title: 'Members' }, + ]; + + return ( +
+
+ {tabs.map((tab) => ( + + ))} + {isConnected && ( +
+ )} + +
+ {/* Safe area for phones with bottom notch */} +
+
+ ); +} diff --git a/web/src/components/ReactionBar.tsx b/web/src/components/ReactionBar.tsx new file mode 100644 index 0000000..beb8ac1 --- /dev/null +++ b/web/src/components/ReactionBar.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { api } from '../lib/api.ts'; + +interface Reaction { + emoji: string; + count: number; + users: string[]; + reacted: boolean; +} + +interface ReactionBarProps { + messageId: string; + reactions: Reaction[]; + onRefresh: () => void; +} + +export function ReactionBar({ messageId, reactions, onRefresh }: ReactionBarProps) { + const [loading, setLoading] = useState(false); + + const toggleReaction = async (emoji: string) => { + if (loading) return; + setLoading(true); + try { + const existing = reactions.find(r => r.emoji === emoji); + if (existing?.reacted) { + await api.delete(`/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`); + } else { + await api.post(`/messages/${messageId}/reactions`, { emoji }); + } + onRefresh(); + } catch (error) { + console.error('Failed to toggle reaction:', error); + } finally { + setLoading(false); + } + }; + + if (reactions.length === 0) return null; + + return ( +
+ {reactions.map((reaction) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ReplyBar.tsx b/web/src/components/ReplyBar.tsx new file mode 100644 index 0000000..37a7000 --- /dev/null +++ b/web/src/components/ReplyBar.tsx @@ -0,0 +1,55 @@ +interface ReplyBarProps { + replyTo: { + id: string; + content: string; + author: { + username: string; + accent_color?: string; + }; + } | null; + onCancel?: () => void; + compact?: boolean; +} + +export function ReplyBar({ replyTo, onCancel, compact }: ReplyBarProps) { + if (!replyTo) return null; + + const truncatedContent = replyTo.content.length > 100 + ? replyTo.content.slice(0, 100) + '...' + : replyTo.content; + + if (compact) { + return ( +
+ + + {replyTo.author.username} + + {truncatedContent} +
+ ); + } + + return ( +
+ + + {replyTo.author.username} + + + {truncatedContent} + + {onCancel && ( + + )} +
+ ); +} diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..5659b27 --- /dev/null +++ b/web/src/components/ThemeToggle.tsx @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; + +type Theme = 'dark' | 'light'; + +export function ThemeToggle() { + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem('dumpster-theme'); + return (stored === 'light' ? 'light' : 'dark'); + }); + + useEffect(() => { + const root = document.documentElement; + if (theme === 'light') { + root.classList.add('light'); + root.classList.remove('dark'); + } else { + root.classList.add('dark'); + root.classList.remove('light'); + } + localStorage.setItem('dumpster-theme', theme); + }, [theme]); + + const toggle = () => { + setTheme(prev => prev === 'dark' ? 'light' : 'dark'); + }; + + return ( + + ); +} diff --git a/web/src/components/TypingIndicator.tsx b/web/src/components/TypingIndicator.tsx new file mode 100644 index 0000000..fa91af5 --- /dev/null +++ b/web/src/components/TypingIndicator.tsx @@ -0,0 +1,33 @@ +import { useTypingStore } from '../stores/typing.ts'; +import { useAuthStore } from '../stores/auth.ts'; + +interface TypingIndicatorProps { + channelId: string; +} + +export function TypingIndicator({ channelId }: TypingIndicatorProps) { + const typingUsers = useTypingStore((state) => state.typingUsers[channelId] || []); + const currentUser = useAuthStore((state) => state.user); + + // Filter out current user + const others = typingUsers.filter(u => u.userId !== currentUser?.id); + + if (others.length === 0) return null; + + const names = others.map(u => u.username); + + let text: string; + if (names.length === 1) { + text = `<${names[0]}> is typing...`; + } else if (names.length === 2) { + text = `<${names[0]}> and <${names[1]}> are typing...`; + } else { + text = `${names.length} users are typing...`; + } + + return ( +
+ {text} +
+ ); +} diff --git a/web/src/stores/presence.ts b/web/src/stores/presence.ts new file mode 100644 index 0000000..1fb17a3 --- /dev/null +++ b/web/src/stores/presence.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; +import type { UserStatus } from './auth.ts'; + +export interface PresenceEntry { + userId: string; + username: string; + status: UserStatus; +} + +interface PresenceState { + presences: Record; + /** Upsert a single user's presence from a WS event. */ + updatePresence: (userId: string, username: string, status: UserStatus) => void; + /** Get a user's current presence; defaults to offline. */ + getPresence: (userId: string) => PresenceEntry; +} + +const DEFAULT_PRESENCE: PresenceEntry = { + userId: '', + username: '', + status: 'offline', +}; + +export const usePresenceStore = create((set, get) => ({ + presences: {}, + + updatePresence: (userId, username, status) => + set((state) => ({ + presences: { + ...state.presences, + [userId]: { userId, username, status }, + }, + })), + + getPresence: (userId) => get().presences[userId] ?? DEFAULT_PRESENCE, +})); diff --git a/web/src/stores/push.ts b/web/src/stores/push.ts new file mode 100644 index 0000000..0b5e173 --- /dev/null +++ b/web/src/stores/push.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand'; +import { api } from '../lib/api.ts'; + +interface PushState { + isSupported: boolean; + isSubscribed: boolean; + permission: NotificationPermission; + subscribe: () => Promise; + unsubscribe: () => Promise; + requestPermission: () => Promise; +} + +function urlBase64ToUint8Array(base64String: string): BufferSource { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export const usePushStore = create((set, get) => ({ + isSupported: 'serviceWorker' in navigator && 'PushManager' in window, + isSubscribed: false, + permission: 'default', + + requestPermission: async () => { + if (!get().isSupported) return 'denied'; + const permission = await Notification.requestPermission(); + set({ permission }); + return permission; + }, + + subscribe: async () => { + if (!get().isSupported) return; + + try { + // Register service worker + const registration = await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + + // Get VAPID public key from server + const { publicKey } = await api.get<{ publicKey: string }>('/push/vapid-key'); + if (!publicKey) return; + + // Subscribe to push + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + // Send subscription to server + await api.post('/push/subscribe', subscription.toJSON()); + set({ isSubscribed: true }); + } catch (error) { + console.error('Failed to subscribe to push:', error); + } + }, + + unsubscribe: async () => { + if (!get().isSupported) return; + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await api.post('/push/unsubscribe', { endpoint: subscription.endpoint }); + await subscription.unsubscribe(); + } + set({ isSubscribed: false }); + } catch (error) { + console.error('Failed to unsubscribe from push:', error); + } + }, +})); diff --git a/web/src/stores/typing.ts b/web/src/stores/typing.ts new file mode 100644 index 0000000..d48602f --- /dev/null +++ b/web/src/stores/typing.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand'; +import { useWebSocketStore } from './ws'; + +interface TypingUser { + userId: string; + username: string; + channelId: string; + timeout: ReturnType; +} + +interface TypingState { + typingUsers: Record; + sendTypingStart: (channelId: string) => void; + _handleTypingEvent: (data: { channel_id: string; user_id: string; username: string }) => void; +} + +export const useTypingStore = create((set, get) => ({ + typingUsers: {}, + + sendTypingStart: (channelId) => { + const ws = useWebSocketStore.getState(); + if (ws.connected) { + ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } }); + } + }, + + _handleTypingEvent: (data) => { + const { channel_id, user_id, username } = data; + + const existing = get().typingUsers[channel_id] || []; + const existingUser = existing.find(u => u.userId === user_id); + if (existingUser) { + clearTimeout(existingUser.timeout); + } + + const timeout = setTimeout(() => { + set(state => ({ + typingUsers: { + ...state.typingUsers, + [channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id), + }, + })); + }, 3000); + + set(state => ({ + typingUsers: { + ...state.typingUsers, + [channel_id]: [ + ...(state.typingUsers?.[channel_id] || []).filter((u: { userId: string }) => u.userId !== user_id), + { userId: user_id, username, channelId: channel_id, timeout }, + ], + }, + })); + }, +})); diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index cf315a6..a67b850 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -2,9 +2,12 @@ import { create } from 'zustand'; import { useMessageStore } from './message.ts'; import { useChannelStore } from './channel.ts'; import { useServerStore } from './server.ts'; +import { usePresenceStore } from './presence.ts'; +import { useTypingStore } from './typing.ts'; import type { Message } from './message.ts'; import type { Channel } from './channel.ts'; import type { Server } from './server.ts'; +import type { UserStatus } from './auth.ts'; type UnknownPayload = Record; @@ -147,6 +150,20 @@ export const useWebSocketStore = create((set, get) => ({ if (server) updateServer(server); break; } + case 'PRESENCE_UPDATE': { + const { user_id, username, status } = data.payload as Record; + if (user_id && username && status) { + usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus); + } + break; + } + case 'TYPING_START': { + const { channel_id, user_id, username } = data.payload as Record; + if (channel_id && user_id && username) { + useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username }); + } + break; + } default: break; } diff --git a/web/src/styles/index.css b/web/src/styles/index.css index fc5524d..a4d2770 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -24,6 +24,20 @@ --gb-border: #665c54; } + /* Light mode overrides */ + .light { + --gb-bg-h: #f9f5d7; + --gb-bg: #fbf1c7; + --gb-bg-s: #ebdbb2; + --gb-bg-t: #d5c4a1; + --gb-bg-f: #bdae93; + --gb-fg: #282828; + --gb-fg-s: #3c3836; + --gb-fg-t: #504945; + --gb-fg-f: #7c6f64; + --gb-border: #a89984; + } + html, body, #root { @apply h-full w-full bg-gb-bg text-gb-fg font-mono; font-size: 14px; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b44132d..9feca98 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -4,6 +4,7 @@ export default { "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], + darkMode: 'class', theme: { extend: { colors: { @@ -24,6 +25,16 @@ export default { 'gb-aqua': '#8ec07c', 'gb-orange': '#fe8019', 'gb-gray': '#928374', + // Light mode overrides + 'gb-light-bg-h': '#f9f5d7', + 'gb-light-bg': '#fbf1c7', + 'gb-light-bg-s': '#ebdbb2', + 'gb-light-bg-t': '#d5c4a1', + 'gb-light-bg-f': '#bdae93', + 'gb-light-fg': '#282828', + 'gb-light-fg-s': '#3c3836', + 'gb-light-fg-t': '#504945', + 'gb-light-fg-f': '#7c6f64', }, fontFamily: { mono: ['"JetBrains Mono"', '"Fira Code"', '"Cascadia Code"', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'monospace'], diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index d218bb6..4341b60 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file