diff --git a/cmd/server/main.go b/cmd/server/main.go index 105f73c..4a63ace 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -93,7 +93,7 @@ func main() { } // Auth handler - authHandler := auth.NewHandler(database.DB, cfg) + authHandler := auth.NewHandler(database.DB, cfg, hub) // Permissions checker permissionsChecker := permissions.NewChecker(database.DB) diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 90b7999..f873528 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -11,6 +11,7 @@ import ( "strings" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) @@ -19,13 +20,15 @@ type Handler struct { db *sql.DB cfg *config.Config sessions *SessionStore + hub *gateway.Hub } -func NewHandler(db *sql.DB, cfg *config.Config) *Handler { +func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler { return &Handler{ db: db, cfg: cfg, sessions: NewSessionStore(db, cfg), + hub: hub, } } @@ -59,6 +62,7 @@ type updateProfileRequest struct { Bio string `json:"bio"` AccentColor string `json:"accent_color"` StatusText string `json:"status_text"` + Status string `json:"status"` } type changePasswordRequest struct { @@ -279,17 +283,29 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest) return } + if req.Status != "" && !isValidStatus(req.Status) { + http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest) + return + } _, err := h.db.ExecContext(r.Context(), ` UPDATE users - SET display_name = $2, bio = $3, accent_color = $4, status_text = $5 + SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status) WHERE id = $1 - `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText) + `, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status) if err != nil { http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) return } + if req.Status != "" && h.hub != nil { + user, err := h.getProfile(r.Context(), userID) + if err == nil { + h.hub.SetUserStatus(userID, req.Status) + h.hub.BroadcastPresence(userID, user.Username, req.Status) + } + } + user, err := h.getProfile(r.Context(), userID) if err != nil { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) @@ -300,6 +316,14 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(user) } +func isValidStatus(status string) bool { + switch status { + case "online", "idle", "dnd", "offline": + return true + } + return false +} + func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) { var user UserProfile return user, h.db.QueryRowContext(ctx, ` diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go index 8028600..6046cf4 100644 --- a/internal/gateway/hub.go +++ b/internal/gateway/hub.go @@ -18,16 +18,17 @@ const ( // PresenceData is the data payload for PRESENCE_UPDATE events. type PresenceData struct { - UserID string `json:"user_id"` - Status string `json:"status"` + UserID string `json:"user_id"` + Username string `json:"username"` + 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 + presence map[string]string // userID -> status + lastActivity map[string]time.Time // userID -> last activity time userServers map[string]map[string]struct{} // userID -> set of serverIDs register chan *Client unregister chan *Client @@ -162,16 +163,7 @@ func (h *Hub) checkIdleUsers() { // 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) - } + setUserStatus(h.db, h.logger, userID, status) } // broadcastPresence sends a PRESENCE_UPDATE event to all connected clients. @@ -185,6 +177,29 @@ func (h *Hub) broadcastPresence(userID, status string) { }) } +// SetUserStatus is the exported form of setUserStatus, used by non-gateway +// handlers that need to persist a status change. +func (h *Hub) SetUserStatus(userID, status string) { + h.setUserStatus(userID, status) +} + +// BroadcastPresence sends a PRESENCE_UPDATE event including the username. +func (h *Hub) BroadcastPresence(userID, username, status string) { + h.mu.Lock() + h.presence[userID] = status + h.lastActivity[userID] = time.Now() + h.mu.Unlock() + + h.BroadcastEvent(Event{ + Type: EventPresenceUpdate, + Data: PresenceData{ + UserID: userID, + Username: username, + Status: status, + }, + }) +} + // Register queues a client for registration with the hub. func (h *Hub) Register(client *Client) { h.register <- client @@ -273,3 +288,16 @@ func (h *Hub) ServerIDForChannel(ctx context.Context, channelID string) (string, err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) return serverID, err } + +func setUserStatus(db *sql.DB, logger *slog.Logger, userID, status string) { + if db == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := db.ExecContext(ctx, `UPDATE users SET status = $1 WHERE id = $2`, status, userID) + if err != nil { + logger.Error("failed to update user status", "user_id", userID, "status", status, "error", err) + } +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 06d5596..933aecf 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -16,6 +16,14 @@ import ( ) type Handler struct { + db *sql.DB + hub *gateway.Hub + mentions *MentionHandler + pushHandler *push.Handler + logger *slog.Logger +} + +type Handler_old struct { db *sql.DB hub *gateway.Hub mentions *MentionHandler @@ -137,6 +145,16 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { // Dispatch push notifications for @mentions go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content) + // Dispatch push notifications to channel members + go func() { + var channelName string + _ = h.db.QueryRowContext(context.Background(), `SELECT name FROM channels WHERE id = $1`, channelID).Scan(&channelName) + if channelName == "" { + channelName = channelID + } + h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content) + }() + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(msg) diff --git a/internal/push/handlers.go b/internal/push/handlers.go index 254b17e..b7ae03d 100644 --- a/internal/push/handlers.go +++ b/internal/push/handlers.go @@ -183,6 +183,77 @@ func (h *Handler) SendPush(ctx context.Context, userID string, payload map[strin } } + + +// SendChannelNotification sends a push notification to all server members (except the author) +// when a new message is posted in a channel. +func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) { + if h.vapidPub == "" || h.vapidPriv == "" { + return + } + + var serverID string + err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) + if err != nil { + h.logger.Error("failed to find channel server", "error", err) + return + } + + body := content + if len(body) > 200 { + body = body[:200] + "..." + } + + payload, _ := json.Marshal(map[string]interface{}{ + "title": "New message in #" + channelName, + "body": body, + "url": "/channels/" + channelID, + }) + + rows, err := h.db.QueryContext(ctx, ` + SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth + FROM push_subscriptions ps + JOIN members m ON m.user_id = ps.user_id + WHERE m.server_id = $1 AND ps.user_id != $2 + `, serverID, authorID) + if err != nil { + h.logger.Error("failed to query push subscriptions", "error", err) + return + } + defer rows.Close() + + for rows.Next() { + var userID, endpoint, p256dh, auth string + if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil { + continue + } + + sub := &webpush.Subscription{ + Endpoint: endpoint, + Keys: webpush.Keys{ + P256dh: p256dh, + Auth: auth, + }, + } + + resp, err := webpush.SendNotification(payload, sub, &webpush.Options{ + Subscriber: h.vapidSubj, + VAPIDPublicKey: h.vapidPub, + VAPIDPrivateKey: h.vapidPriv, + TTL: 30, + }) + if err != nil { + h.logger.Error("failed to send push", "error", err, "user_id", userID) + if resp != nil && resp.StatusCode == 410 { + h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) + } + continue + } + if resp != nil { + resp.Body.Close() + } + } +} // GenerateVAPIDKeys generates a new VAPID key pair. func GenerateVAPIDKeys() (publicKey, privateKey string, err error) { privateKeyBytes := make([]byte, 32) diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index d387d9e..226bc3d 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { useMessageStore } from "../stores/message.ts"; import { useChannelStore } from "../stores/channel.ts"; import { useServerStore } from "../stores/server.ts"; +import { useMemberStore } from "../stores/member.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; +import { MentionDropdown } from "./MentionDropdown"; function formatTime(iso: string): string { const date = new Date(iso); @@ -11,6 +13,41 @@ function formatTime(iso: string): string { return `${hours}:${minutes}`; } + +function renderContent(content: string, memberUsernames: Set) { + // Split on @username, preserving mentions. + const parts: (string | { mention: string })[] = []; + const mentionRe = /@([a-zA-Z0-9_.-]+)/g; + let last = 0; + let match: RegExpExecArray | null; + while ((match = mentionRe.exec(content)) !== null) { + if (match.index > last) { + parts.push(content.slice(last, match.index)); + } + const username = match[1]; + if (memberUsernames.has(username)) { + parts.push({ mention: username }); + } else { + parts.push(match[0]); + } + last = mentionRe.lastIndex; + } + if (last < content.length) { + parts.push(content.slice(last)); + } + + return parts.map((part, idx) => { + if (typeof part === "string") { + return {part}; + } + return ( + + @{part.mention} + + ); + }); +} + export function ChatArea() { const activeChannelId = useChannelStore((s) => s.activeChannelId); const channelsByServer = useChannelStore((s) => s.channelsByServer); @@ -21,15 +58,23 @@ export function ChatArea() { const isLoading = useMessageStore((s) => s.isLoading); const fetchMessages = useMessageStore((s) => s.fetchMessages); const sendMessage = useMessageStore((s) => s.sendMessage); + const membersByServer = useMemberStore((s) => s.membersByServer); const [input, setInput] = useState(""); const [error, setError] = useState(null); const [showGifPicker, setShowGifPicker] = useState(false); + const [mentionQuery, setMentionQuery] = useState(null); const bottomRef = useRef(null); + const inputRef = useRef(null); const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; const activeChannel = channels.find((c) => c.id === activeChannelId); + const members = activeServerId ? membersByServer[activeServerId] || [] : []; + const memberUsernames = useMemo( + () => new Set(members.map((m) => m.username)), + [members], + ); useEffect(() => { if (activeChannelId) { @@ -42,6 +87,49 @@ export function ChatArea() { bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const cursor = e.target.selectionStart ?? value.length; + setInput(value); + + // Detect an unfinished mention at cursor position. + const beforeCursor = value.slice(0, cursor); + const atIndex = beforeCursor.lastIndexOf("@"); + if (atIndex === -1) { + setMentionQuery(null); + return; + } + const between = beforeCursor.slice(atIndex + 1); + if (between.includes(" ") || between.includes("\n")) { + setMentionQuery(null); + return; + } + setMentionQuery(between); + }; + + const handleMentionSelect = (username: string) => { + const inputEl = inputRef.current; + if (!inputEl) { + setInput((prev) => `${prev}${username} `); + setMentionQuery(null); + return; + } + const cursor = inputEl.selectionStart ?? input.length; + const beforeCursor = input.slice(0, cursor); + const atIndex = beforeCursor.lastIndexOf("@"); + if (atIndex === -1) return; + const before = input.slice(0, atIndex); + const after = input.slice(cursor); + const next = `${before}@${username} ${after}`; + setInput(next); + setMentionQuery(null); + requestAnimationFrame(() => { + const pos = atIndex + username.length + 2; // @username + space + inputEl.focus(); + inputEl.setSelectionRange(pos, pos); + }); + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!activeChannelId || !input.trim()) return; @@ -49,6 +137,7 @@ export function ChatArea() { try { await sendMessage(activeChannelId, input.trim()); setInput(""); + setMentionQuery(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send"); } @@ -87,7 +176,9 @@ export function ChatArea() { <{message.author_username}> {" "} - {message.content} + + {renderContent(message.content, memberUsernames)} + ))}
@@ -105,16 +196,26 @@ export function ChatArea() {

ERR: {error}

)} -
+ {">"} - setInput(e.target.value)} - placeholder="type a message..." - className="terminal-input flex-1 min-w-0" - disabled={!activeChannelId} - /> +
+ + {mentionQuery !== null && ( + + )} +
+ {showStatusMenu && ( +
+ {STATUS_CYCLE.map((s) => ( + + ))} +
+ )} + [SETTINGS] + ))} + + ); +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 372d0d0..dad8790 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -21,6 +21,7 @@ export interface UpdateProfilePayload { bio?: string; accent_color?: string; status_text?: string; + status?: UserStatus; } interface AuthState { diff --git a/web/src/stores/member.ts b/web/src/stores/member.ts index e165e24..4dea562 100644 --- a/web/src/stores/member.ts +++ b/web/src/stores/member.ts @@ -14,6 +14,7 @@ interface MemberState { membersByServer: Record; isLoading: boolean; fetchMembers: (serverId: string) => Promise; + updateMemberStatus: (userId: string, status: Member["status"]) => void; } export const useMemberStore = create((set) => ({ @@ -34,4 +35,15 @@ export const useMemberStore = create((set) => ({ set({ isLoading: false }); } }, + + updateMemberStatus: (userId, status) => + set((state) => { + const next: Record = {}; + for (const [serverId, list] of Object.entries(state.membersByServer)) { + next[serverId] = list.map((m) => + m.id === userId ? { ...m, status } : m, + ); + } + return { membersByServer: next }; + }), }));