Files
dumpsterChat/internal/gateway/hub.go
T
hobokenchicken bb650ac2a0 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
2026-06-28 16:44:39 -04:00

210 lines
5.2 KiB
Go

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{}
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(db *sql.DB, logger *slog.Logger) *Hub {
return &Hub{
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:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
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:
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- message:
default:
go func(c *Client) {
h.unregister <- c
}(client)
}
}
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
}
// Unregister queues a client for removal from the hub.
func (h *Hub) Unregister(client *Client) {
h.unregister <- client
}
// BroadcastEvent serializes an Event and broadcasts it to all connected clients.
func (h *Hub) BroadcastEvent(event Event) {
data, err := json.Marshal(event)
if err != nil {
h.logger.Error("failed to marshal event", "type", event.Type, "error", err)
return
}
h.broadcast <- data
}
// ClientCount returns the number of connected clients.
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}