feat: add SMTP email support and fix profile/permissions bugs
This commit is contained in:
+9
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/dm"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite"
|
||||||
@@ -98,8 +99,11 @@ func main() {
|
|||||||
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email mailer
|
||||||
|
mailer := email.NewMailer(cfg.SMTP)
|
||||||
|
|
||||||
// Auth handler
|
// Auth handler
|
||||||
authHandler := auth.NewHandler(database.DB, cfg, hub)
|
authHandler := auth.NewHandler(database.DB, cfg, hub, mailer)
|
||||||
|
|
||||||
// Permissions checker
|
// Permissions checker
|
||||||
permissionsChecker := permissions.NewChecker(database.DB)
|
permissionsChecker := permissions.NewChecker(database.DB)
|
||||||
@@ -225,6 +229,9 @@ func main() {
|
|||||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
pushHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
// Read receipts
|
// Read receipts
|
||||||
rsHandler := readstate.NewHandler(database.DB, logger)
|
rsHandler := readstate.NewHandler(database.DB, logger)
|
||||||
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
r.Put("/channels/{channelID}/read", rsHandler.MarkRead)
|
||||||
@@ -333,7 +340,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Static file serving for production (SPA)
|
// Static file serving for production (SPA)
|
||||||
staticDir := "/srv/web"
|
staticDir := "web/dist"
|
||||||
if _, err := os.Stat(staticDir); err == nil {
|
if _, err := os.Stat(staticDir); err == nil {
|
||||||
fileServer := http.FileServer(http.Dir(staticDir))
|
fileServer := http.FileServer(http.Dir(staticDir))
|
||||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / TRUE 1785435515 dumpster_session 720a3aad74029e6c8e0c4b93f8db0b48381afc5355e1c1345143a0f5e3d5ae34
|
||||||
Executable
BIN
Binary file not shown.
@@ -17,7 +17,7 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: int(duration.Seconds()),
|
MaxAge: int(duration.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.getProfile(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := generateToken()
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
|
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
|
||||||
|
`, userID, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appURL := "http://" + h.cfg.Host
|
||||||
|
if h.cfg.Port != "80" && h.cfg.Port != "443" {
|
||||||
|
appURL += ":" + h.cfg.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.mailer.SendVerificationEmail(user.Email, user.Username, token, appURL); err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to send email"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
DELETE FROM user_tokens
|
||||||
|
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
|
||||||
|
RETURNING user_id
|
||||||
|
`, req.Token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
|
UPDATE users SET email_verified = TRUE WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID, username string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT id, username FROM users WHERE email = $1
|
||||||
|
`, req.Email).Scan(&userID, &username)
|
||||||
|
if err != nil {
|
||||||
|
// Silent fail to prevent email enumeration
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := generateToken()
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO user_tokens (user_id, token, type, expires_at)
|
||||||
|
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
|
||||||
|
`, userID, token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appURL := "http://" + h.cfg.Host
|
||||||
|
if h.cfg.Port != "80" && h.cfg.Port != "443" {
|
||||||
|
appURL += ":" + h.cfg.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mailer.SendPasswordResetEmail(req.Email, username, token, appURL)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
http.Error(w, `{"error":"password too short"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
DELETE FROM user_tokens
|
||||||
|
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
|
||||||
|
RETURNING user_id
|
||||||
|
`, req.Token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to hash password"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
|
UPDATE users SET password_hash = $2 WHERE id = $1
|
||||||
|
`, userID, hash)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to update user"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all sessions to force re-login
|
||||||
|
h.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = $1`, userID)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is an admin across the whole system.
|
||||||
|
// For now, let's assume if they have ADMINISTRATOR on ANY server, they can announce.
|
||||||
|
// Or maybe there is a global admin flag? Let's check permissions.
|
||||||
|
// Actually, let's just make sure they have MANAGE_SERVER on a specific server or something.
|
||||||
|
// DumpsterChat doesn't have system-level admins out of the box, only Server-level.
|
||||||
|
// Let's require them to be the owner of at least one server, or have ADMINISTRATOR on a server?
|
||||||
|
// The prompt just said "admin sent announcements". Let's allow it for users who are in any server with ADMINISTRATOR.
|
||||||
|
var isAdmin bool
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM member_roles mr
|
||||||
|
JOIN roles r ON mr.role_id = r.id
|
||||||
|
WHERE mr.user_id = $1 AND (r.permissions & $2) = $2
|
||||||
|
)
|
||||||
|
`, userID, permissions.ADMINISTRATOR).Scan(&isAdmin)
|
||||||
|
|
||||||
|
if err != nil || !isAdmin {
|
||||||
|
http.Error(w, `{"error":"must be an administrator"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `SELECT email FROM users`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to fetch users"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var email string
|
||||||
|
if err := rows.Scan(&email); err == nil && email != "" {
|
||||||
|
// This is synchronous and could block, ideally use a queue, but this is fine for now
|
||||||
|
h.mailer.SendAnnouncement(email, req.Subject, req.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
+103
-26
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/email"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -21,14 +22,16 @@ type Handler struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessions *SessionStore
|
sessions *SessionStore
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
|
mailer *email.Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub) *Handler {
|
func NewHandler(db *sql.DB, cfg *config.Config, hub *gateway.Hub, mailer *email.Mailer) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
sessions: NewSessionStore(db, cfg),
|
sessions: NewSessionStore(db, cfg),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
mailer: mailer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +39,14 @@ func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
|||||||
r.Post("/register", h.Register)
|
r.Post("/register", h.Register)
|
||||||
r.Post("/login/password", h.Login)
|
r.Post("/login/password", h.Login)
|
||||||
r.Post("/logout", h.Logout)
|
r.Post("/logout", h.Logout)
|
||||||
|
r.Post("/verify-email", h.VerifyEmail)
|
||||||
|
r.Post("/request-password-reset", h.RequestPasswordReset)
|
||||||
|
r.Post("/reset-password", h.ResetPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||||
|
r.Post("/auth/request-verification", h.RequestVerification)
|
||||||
|
r.Post("/auth/admin/announce", h.AdminAnnounce)
|
||||||
r.Get("/auth/me", h.Me)
|
r.Get("/auth/me", h.Me)
|
||||||
r.Patch("/auth/me", h.UpdateProfile)
|
r.Patch("/auth/me", h.UpdateProfile)
|
||||||
r.Put("/auth/me/password", h.ChangePassword)
|
r.Put("/auth/me/password", h.ChangePassword)
|
||||||
@@ -210,16 +218,16 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type updateProfileRequest struct {
|
type updateProfileRequest struct {
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
AccentColor string `json:"accent_color"`
|
AccentColor *string `json:"accent_color"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText *string `json:"status_text"`
|
||||||
Status string `json:"status"`
|
Status *string `json:"status"`
|
||||||
BannerURL string `json:"banner_url"`
|
BannerURL *string `json:"banner_url"`
|
||||||
Tagline string `json:"tagline"`
|
Tagline *string `json:"tagline"`
|
||||||
Pronouns string `json:"pronouns"`
|
Pronouns *string `json:"pronouns"`
|
||||||
SocialLinks []struct {
|
SocialLinks *[]struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
} `json:"social_links"`
|
} `json:"social_links"`
|
||||||
@@ -410,8 +418,16 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Failure 401 {object} map[string]string
|
// @Failure 401 {object} map[string]string
|
||||||
// @Router /auth/me [get]
|
// @Router /auth/me [get]
|
||||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
cookie, _ := r.Cookie(h.cfg.Session.CookieName)
|
||||||
|
if cookie != nil {
|
||||||
|
fmt.Printf("Me Request: Cookie present! value=%s\n", cookie.Value)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Me Request: NO COOKIE!\n")
|
||||||
|
}
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
fmt.Printf("Me Request: NO USER ID IN CONTEXT\n")
|
||||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -450,52 +466,113 @@ func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Bio) > 250 {
|
// Fetch existing user to merge fields
|
||||||
|
var existing struct {
|
||||||
|
DisplayName string
|
||||||
|
Bio string
|
||||||
|
AccentColor string
|
||||||
|
StatusText string
|
||||||
|
Status string
|
||||||
|
BannerURL string
|
||||||
|
Tagline string
|
||||||
|
Pronouns string
|
||||||
|
AvatarURL string
|
||||||
|
SocialLinks []struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var socialLinksBytes []byte
|
||||||
|
var bannerURL, tagline, pronouns, avatar sql.NullString
|
||||||
|
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT display_name, bio, accent_color, status_text, status, banner_url, tagline, pronouns, avatar, social_links
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, userID).Scan(&existing.DisplayName, &existing.Bio, &existing.AccentColor, &existing.StatusText, &existing.Status, &bannerURL, &tagline, &pronouns, &avatar, &socialLinksBytes)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.BannerURL = bannerURL.String
|
||||||
|
existing.Tagline = tagline.String
|
||||||
|
existing.Pronouns = pronouns.String
|
||||||
|
existing.AvatarURL = avatar.String
|
||||||
|
if len(socialLinksBytes) > 0 {
|
||||||
|
json.Unmarshal(socialLinksBytes, &existing.SocialLinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to merge strings
|
||||||
|
mergeStr := func(ptr *string, old string) string {
|
||||||
|
if ptr != nil {
|
||||||
|
return *ptr
|
||||||
|
}
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
newBio := mergeStr(req.Bio, existing.Bio)
|
||||||
|
newStatusText := mergeStr(req.StatusText, existing.StatusText)
|
||||||
|
newDisplayName := mergeStr(req.DisplayName, existing.DisplayName)
|
||||||
|
newTagline := mergeStr(req.Tagline, existing.Tagline)
|
||||||
|
newPronouns := mergeStr(req.Pronouns, existing.Pronouns)
|
||||||
|
newAccentColor := mergeStr(req.AccentColor, existing.AccentColor)
|
||||||
|
newStatus := mergeStr(req.Status, existing.Status)
|
||||||
|
newBannerURL := mergeStr(req.BannerURL, existing.BannerURL)
|
||||||
|
newAvatarURL := mergeStr(req.AvatarURL, existing.AvatarURL)
|
||||||
|
|
||||||
|
if len(newBio) > 250 {
|
||||||
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.StatusText) > 128 {
|
if len(newStatusText) > 128 {
|
||||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.DisplayName) > 32 {
|
if len(newDisplayName) > 32 {
|
||||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Tagline) > 128 {
|
if len(newTagline) > 128 {
|
||||||
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"tagline too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Pronouns) > 40 {
|
if len(newPronouns) > 40 {
|
||||||
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"pronouns too long"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
if newAccentColor != "" && len(newAccentColor) != 7 {
|
||||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Status != "" && !isValidStatus(req.Status) {
|
if newStatus != "" && !isValidStatus(newStatus) {
|
||||||
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
http.Error(w, `{"error":"invalid status"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socialLinksJSON, _ := json.Marshal(req.SocialLinks)
|
var socialLinksJSON []byte
|
||||||
_, err := h.db.ExecContext(r.Context(), `
|
if req.SocialLinks != nil {
|
||||||
|
socialLinksJSON, _ = json.Marshal(*req.SocialLinks)
|
||||||
|
} else {
|
||||||
|
socialLinksJSON, _ = json.Marshal(existing.SocialLinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = COALESCE(NULLIF($6, ''), status),
|
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5, status = $6,
|
||||||
banner_url = NULLIF($7, ''), tagline = NULLIF($8, ''), pronouns = NULLIF($9, ''), social_links = $10, avatar = COALESCE(NULLIF($11, ''), avatar)
|
banner_url = $7, tagline = $8, pronouns = $9, social_links = $10, avatar = $11
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText, req.Status, req.BannerURL, req.Tagline, req.Pronouns, socialLinksJSON, req.AvatarURL)
|
`, userID, newDisplayName, newBio, newAccentColor, newStatusText, newStatus, newBannerURL, newTagline, newPronouns, socialLinksJSON, newAvatarURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != "" && h.hub != nil {
|
if req.Status != nil && h.hub != nil {
|
||||||
user, err := h.getProfile(r.Context(), userID)
|
user, err := h.getProfile(r.Context(), userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
h.hub.SetUserStatus(userID, req.Status)
|
h.hub.SetUserStatus(userID, *req.Status)
|
||||||
h.hub.BroadcastPresence(userID, user.Username, req.Status)
|
h.hub.BroadcastPresence(userID, user.Username, *req.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,16 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channels = append(channels, ch)
|
|
||||||
|
// Check VIEW_CHANNEL permission
|
||||||
|
allowed, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
channels = append(channels, ch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ type Config struct {
|
|||||||
WebPush WebPushConfig
|
WebPush WebPushConfig
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
Giphy GiphyConfig
|
Giphy GiphyConfig
|
||||||
|
SMTP SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -134,6 +142,12 @@ func Load() *Config {
|
|||||||
Giphy: GiphyConfig{
|
Giphy: GiphyConfig{
|
||||||
APIKey: getEnv("GIPHY_API_KEY", ""),
|
APIKey: getEnv("GIPHY_API_KEY", ""),
|
||||||
},
|
},
|
||||||
|
SMTP: SMTPConfig{
|
||||||
|
Host: getEnv("SMTP_HOST", ""),
|
||||||
|
Port: getEnv("SMTP_PORT", ""),
|
||||||
|
Username: getEnv("SMTP_USERNAME", ""),
|
||||||
|
Password: getEnv("SMTP_PASSWORD", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,19 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
type VARCHAR(32) NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -228,14 +228,14 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
|
func (h *Handler) buildResponse(ctx context.Context, convID string) (conversationResponse, error) {
|
||||||
var resp conversationResponse
|
var resp conversationResponse
|
||||||
err := h.db.QueryRowContext(ctx, `
|
err := h.db.QueryRowContext(ctx, `
|
||||||
SELECT id, type, name, created_at::text FROM conversations WHERE id = $1
|
SELECT id, type, COALESCE(name, ''), created_at::text FROM conversations WHERE id = $1
|
||||||
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
|
`, convID).Scan(&resp.ID, &resp.Type, &resp.Name, &resp.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
memberRows, err := h.db.QueryContext(ctx, `
|
memberRows, err := h.db.QueryContext(ctx, `
|
||||||
SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, '')
|
SELECT u.id, u.username, COALESCE(u.display_name, ''), COALESCE(u.avatar, '')
|
||||||
FROM conversation_members cm
|
FROM conversation_members cm
|
||||||
JOIN users u ON u.id = cm.user_id
|
JOIN users u ON u.id = cm.user_id
|
||||||
WHERE cm.conversation_id = $1
|
WHERE cm.conversation_id = $1
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mailer struct {
|
||||||
|
cfg config.SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailer(cfg config.SMTPConfig) *Mailer {
|
||||||
|
return &Mailer{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) Send(to, subject, body string) error {
|
||||||
|
if m.cfg.Host == "" || m.cfg.Port == "" {
|
||||||
|
return fmt.Errorf("SMTP is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||||
|
|
||||||
|
msg := []byte("To: " + to + "\r\n" +
|
||||||
|
"Subject: " + subject + "\r\n" +
|
||||||
|
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||||
|
"\r\n" +
|
||||||
|
body + "\r\n")
|
||||||
|
|
||||||
|
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||||
|
|
||||||
|
// Some servers (like STARTTLS on 587) require standard Dial, then StartTLS
|
||||||
|
// We'll use smtp.SendMail which does STARTTLS automatically if the server supports it
|
||||||
|
err := smtp.SendMail(addr, auth, m.cfg.Username, []string{to}, msg)
|
||||||
|
if err != nil {
|
||||||
|
// If standard SendMail fails due to TLS issues, fallback to manual TLS/STARTTLS if needed
|
||||||
|
// But smtp.SendMail is usually sufficient for standard STARTTLS on port 587.
|
||||||
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) SendVerificationEmail(to, username, token, appURL string) error {
|
||||||
|
subject := "Verify your dumpsterChat email"
|
||||||
|
link := fmt.Sprintf("%s/verify-email?token=%s", appURL, token)
|
||||||
|
body := fmt.Sprintf(`
|
||||||
|
<h1>Welcome to dumpsterChat, %s!</h1>
|
||||||
|
<p>Please verify your email address by clicking the link below:</p>
|
||||||
|
<p><a href="%s">%s</a></p>
|
||||||
|
<p>If you did not sign up for this account, you can safely ignore this email.</p>
|
||||||
|
`, username, link, link)
|
||||||
|
|
||||||
|
return m.Send(to, subject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) SendPasswordResetEmail(to, username, token, appURL string) error {
|
||||||
|
subject := "Reset your dumpsterChat password"
|
||||||
|
link := fmt.Sprintf("%s/reset-password?token=%s", appURL, token)
|
||||||
|
body := fmt.Sprintf(`
|
||||||
|
<h1>Password Reset Request</h1>
|
||||||
|
<p>Hi %s,</p>
|
||||||
|
<p>We received a request to reset your password. Click the link below to set a new password:</p>
|
||||||
|
<p><a href="%s">%s</a></p>
|
||||||
|
<p>If you did not request this, please ignore this email.</p>
|
||||||
|
`, username, link, link)
|
||||||
|
|
||||||
|
return m.Send(to, subject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) SendAnnouncement(to, subject, body string) error {
|
||||||
|
return m.Send(to, subject, body)
|
||||||
|
}
|
||||||
@@ -96,13 +96,14 @@ func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Layer channel overrides: apply deny first (subtract), then allow (add).
|
// Layer channel overrides: apply deny first (subtract), then allow (add).
|
||||||
// Role-based overrides from all user roles, then user-specific override.
|
// Role-based overrides from all user roles, plus the @everyone role, then user-specific override.
|
||||||
rows, err := c.db.QueryContext(ctx, `
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
SELECT co.allow_bitflags, co.deny_bitflags
|
SELECT co.allow_bitflags, co.deny_bitflags
|
||||||
FROM channel_overrides co
|
FROM channel_overrides co
|
||||||
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
WHERE co.channel_id = $1 AND co.target_type = 'role'
|
||||||
AND co.target_id IN (
|
AND (
|
||||||
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3
|
co.target_id IN (SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3)
|
||||||
|
OR co.target_id = (SELECT id FROM roles WHERE server_id = $3 AND is_default = TRUE LIMIT 1)
|
||||||
)
|
)
|
||||||
`, channelID, userID, serverID)
|
`, channelID, userID, serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
webpush "github.com/SherClockHolmes/webpush-go"
|
webpush "github.com/SherClockHolmes/webpush-go"
|
||||||
@@ -31,10 +32,10 @@ func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.HandleFunc("POST /push/subscribe", h.Subscribe)
|
r.Post("/push/subscribe", h.Subscribe)
|
||||||
r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe)
|
r.Post("/push/unsubscribe", h.Unsubscribe)
|
||||||
r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey)
|
r.Get("/push/vapid-public-key", h.GetPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Get VAPID public key
|
// @Summary Get VAPID public key
|
||||||
|
|||||||
@@ -358,12 +358,34 @@ func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(roles)
|
json.NewEncoder(w).Encode(roles)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedDefaultRole inserts the @everyone role for a newly created server.
|
// SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server.
|
||||||
// Call this after creating a server (e.g. from the server creation handler).
|
// Call this after creating a server (e.g. from the server creation handler).
|
||||||
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
||||||
|
// @everyone
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO roles (server_id, name, permissions, position, is_default)
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||||
VALUES ($1, '@everyone', $2, 0, TRUE)
|
VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
|
||||||
`, serverID, permissions.DefaultEveryonePermissions)
|
`, serverID, permissions.DefaultEveryonePermissions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
adminPerms := permissions.ADMINISTRATOR
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||||
|
VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75')
|
||||||
|
`, serverID, adminPerms)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderator
|
||||||
|
modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE
|
||||||
|
_, err = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
||||||
|
VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef')
|
||||||
|
`, serverID, modPerms)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { LoginForm } from './components/LoginForm.tsx';
|
import { LoginForm } from './components/LoginForm.tsx';
|
||||||
import { Layout } from './components/Layout.tsx';
|
import { Layout } from './components/Layout.tsx';
|
||||||
import { ChatArea } from './components/ChatArea.tsx';
|
import { ChatArea } from './components/ChatArea.tsx';
|
||||||
@@ -16,6 +17,17 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const fetchMe = useAuthStore((state) => state.fetchMe);
|
||||||
|
const [init, setInit] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe().finally(() => setInit(true));
|
||||||
|
}, [fetchMe]);
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
return <div className="h-screen w-screen bg-gb-bg flex items-center justify-center text-gb-fg-s font-mono text-xs">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
|||||||
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
|
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
|
||||||
const newChannel = {
|
const newChannel = {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
serverId: result.server_id,
|
server_id: result.server_id,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
type: result.type,
|
type: result.type,
|
||||||
category: result.category || null,
|
category: result.category || null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
||||||
import { useWebSocketStore } from '../stores/ws.ts';
|
import { useWebSocketStore } from '../stores/ws.ts';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
import { useLayoutStore } from '../stores/layout.ts';
|
||||||
import { ServerBar } from './ServerBar.tsx';
|
import { ServerBar } from './ServerBar.tsx';
|
||||||
import { ChannelList } from './ChannelList.tsx';
|
import { ChannelList } from './ChannelList.tsx';
|
||||||
import { ConversationList } from './ConversationList.tsx';
|
import { ConversationList } from './ConversationList.tsx';
|
||||||
@@ -29,7 +30,6 @@ export function Layout() {
|
|||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const isLoading = useAuthStore((state) => state.isLoading);
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const fetchMe = useAuthStore((state) => state.fetchMe);
|
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||||
@@ -38,14 +38,14 @@ export function Layout() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
const [dmMode, setDmMode] = useState(false);
|
const isDM = useLayoutStore((s) => s.isDM);
|
||||||
|
const setDM = useLayoutStore((s) => s.setDM);
|
||||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMe();
|
|
||||||
wsConnect();
|
wsConnect();
|
||||||
return () => { wsDisconnect(); };
|
return () => { wsDisconnect(); };
|
||||||
}, [fetchMe, wsConnect, wsDisconnect]);
|
}, [wsConnect, wsDisconnect]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
@@ -120,9 +120,9 @@ export function Layout() {
|
|||||||
<div className="flex-1 flex min-h-0">
|
<div className="flex-1 flex min-h-0">
|
||||||
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
|
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDmMode(false)}
|
onClick={() => setDM(false)}
|
||||||
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
||||||
!dmMode
|
!isDM
|
||||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
||||||
}`}
|
}`}
|
||||||
@@ -132,11 +132,11 @@ export function Layout() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDmMode(true);
|
setDM(true);
|
||||||
setActiveServer(null);
|
setActiveServer(null);
|
||||||
}}
|
}}
|
||||||
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
||||||
dmMode
|
isDM
|
||||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
||||||
}`}
|
}`}
|
||||||
@@ -146,7 +146,7 @@ export function Layout() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ServerBar />
|
<ServerBar />
|
||||||
{dmMode ? <ConversationList /> : <ChannelList />}
|
{isDM ? <ConversationList /> : <ChannelList />}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<VoicePanel />
|
<VoicePanel />
|
||||||
@@ -155,7 +155,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!dmMode && <MemberList />}
|
{!isDM && <MemberList />}
|
||||||
</div>
|
</div>
|
||||||
{showServerSettings && activeServerId && (
|
{showServerSettings && activeServerId && (
|
||||||
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { usePresenceStore } from "../stores/presence.ts";
|
|||||||
import type { UserStatus } from "../stores/auth.ts";
|
import type { UserStatus } from "../stores/auth.ts";
|
||||||
import { MemberContextMenu } from "./MemberContextMenu.tsx";
|
import { MemberContextMenu } from "./MemberContextMenu.tsx";
|
||||||
import { useConversationStore } from "../stores/conversation.ts";
|
import { useConversationStore } from "../stores/conversation.ts";
|
||||||
|
import { useLayoutStore } from "../stores/layout.ts";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
function statusIcon(status: UserStatus): string {
|
function statusIcon(status: UserStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -53,7 +55,10 @@ function MemberRow({ member }: { member: Member }) {
|
|||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const userPerms = useServerStore((s) => s.userPermissions);
|
const userPerms = useServerStore((s) => s.userPermissions);
|
||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
|
const setActiveServer = useServerStore((s) => s.setActiveServer);
|
||||||
const createConversation = useConversationStore((s) => s.createConversation);
|
const createConversation = useConversationStore((s) => s.createConversation);
|
||||||
|
const setDM = useLayoutStore((s) => s.setDM);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const canKick = userPerms?.kick_members ?? false;
|
const canKick = userPerms?.kick_members ?? false;
|
||||||
const canBan = userPerms?.ban_members ?? false;
|
const canBan = userPerms?.ban_members ?? false;
|
||||||
@@ -79,9 +84,16 @@ function MemberRow({ member }: { member: Member }) {
|
|||||||
canKick={canKick}
|
canKick={canKick}
|
||||||
canBan={canBan}
|
canBan={canBan}
|
||||||
canMute={canMute}
|
canMute={canMute}
|
||||||
onMessage={() => {
|
onMessage={async () => {
|
||||||
createConversation([member.id]);
|
try {
|
||||||
setMenuOpen(false);
|
const conv = await createConversation([member.id]);
|
||||||
|
setMenuOpen(false);
|
||||||
|
setActiveServer(null);
|
||||||
|
setDM(true);
|
||||||
|
navigate(`/dm/${conv.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start DM:", err);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKick={(_reason) => {
|
onKick={(_reason) => {
|
||||||
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
|
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
|
||||||
|
|||||||
@@ -15,7 +15,19 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
|||||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
|
||||||
|
const members = useMemo(() => {
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
if (activeServerId && membersByServer[activeServerId]) {
|
||||||
|
membersByServer[activeServerId].forEach((m) => map.set(m.id, m));
|
||||||
|
} else {
|
||||||
|
// Global search if no active server
|
||||||
|
Object.values(membersByServer).forEach((list) => {
|
||||||
|
list.forEach((m) => map.set(m.id, m));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}, [activeServerId, membersByServer]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
|
|||||||
@@ -322,20 +322,40 @@ export function UserSettings() {
|
|||||||
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||||
NOTIFICATIONS:
|
NOTIFICATIONS:
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
type="button"
|
<button
|
||||||
onClick={isSubscribed ? unsubscribe : subscribe}
|
type="button"
|
||||||
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
onClick={async () => {
|
||||||
>
|
if (Notification.permission !== 'granted') {
|
||||||
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
|
await Notification.requestPermission();
|
||||||
</button>
|
// Force a re-render to update the permission status
|
||||||
<span className="text-gb-fg-f text-xs font-mono">
|
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
|
||||||
{isSubscribed ? '● subscribed' : '○ not subscribed'}
|
}
|
||||||
</span>
|
}}
|
||||||
|
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
||||||
|
>
|
||||||
|
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
|
||||||
|
</button>
|
||||||
|
<span className="text-gb-fg-f text-xs font-mono">
|
||||||
|
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={isSubscribed ? unsubscribe : subscribe}
|
||||||
|
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
|
||||||
|
>
|
||||||
|
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
|
||||||
|
</button>
|
||||||
|
<span className="text-gb-fg-f text-xs font-mono">
|
||||||
|
{isSubscribed ? '● subscribed' : '○ not subscribed'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
<p className="text-gb-fg-f text-xs font-mono mt-2">
|
||||||
Receive push notifications for @mentions and DMs when the app is closed.
|
Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
fetchMe: async () => {
|
fetchMe: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const user = await api.get<User>("/auth/me");
|
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type ChannelType = 'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'li
|
|||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
serverId: string;
|
server_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
@@ -52,22 +52,22 @@ export const useChannelStore = create<ChannelState>((set) => ({
|
|||||||
|
|
||||||
addChannel: (channel) =>
|
addChannel: (channel) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[channel.serverId] || [];
|
const list = state.channelsByServer[channel.server_id] || [];
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.channelsByServer,
|
...state.channelsByServer,
|
||||||
[channel.serverId]: [...list, channel],
|
[channel.server_id]: [...list, channel],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateChannel: (channel) =>
|
updateChannel: (channel) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const list = state.channelsByServer[channel.serverId] || [];
|
const list = state.channelsByServer[channel.server_id] || [];
|
||||||
return {
|
return {
|
||||||
channelsByServer: {
|
channelsByServer: {
|
||||||
...state.channelsByServer,
|
...state.channelsByServer,
|
||||||
[channel.serverId]: list.map((c) => (c.id === channel.id ? channel : c)),
|
[channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const useTypingStore = create<TypingState>((set, get) => ({
|
|||||||
sendTypingStart: (channelId) => {
|
sendTypingStart: (channelId) => {
|
||||||
const ws = useWebSocketStore.getState();
|
const ws = useWebSocketStore.getState();
|
||||||
if (ws.connected) {
|
if (ws.connected) {
|
||||||
ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } });
|
ws.send({ type: 'TYPING_START', data: { channel_id: channelId } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
import { useWebSocketStore } from './ws.ts';
|
import { useWebSocketStore } from './ws.ts';
|
||||||
import { useAuthStore } from './auth.ts';
|
|
||||||
|
|
||||||
export interface VoiceParticipant {
|
export interface VoiceParticipant {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -201,7 +201,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
const wsSend = useWebSocketStore.getState().send;
|
const wsSend = useWebSocketStore.getState().send;
|
||||||
wsSend({
|
wsSend({
|
||||||
type: 'VOICE_JOIN',
|
type: 'VOICE_JOIN',
|
||||||
payload: { room_name: channelId },
|
data: { room_name: channelId },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
@@ -220,7 +220,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
if (channelId) {
|
if (channelId) {
|
||||||
wsSend({
|
wsSend({
|
||||||
type: 'VOICE_LEAVE',
|
type: 'VOICE_LEAVE',
|
||||||
payload: { room_name: channelId },
|
data: { room_name: channelId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
const wsSend = useWebSocketStore.getState().send;
|
const wsSend = useWebSocketStore.getState().send;
|
||||||
wsSend({
|
wsSend({
|
||||||
type: 'VOICE_MUTE',
|
type: 'VOICE_MUTE',
|
||||||
payload: { room_name: get().currentRoom, muted: newMuted },
|
data: { room_name: get().currentRoom, muted: newMuted },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
const wsSend = useWebSocketStore.getState().send;
|
const wsSend = useWebSocketStore.getState().send;
|
||||||
wsSend({
|
wsSend({
|
||||||
type: 'VOICE_DEAFEN',
|
type: 'VOICE_DEAFEN',
|
||||||
payload: { room_name: get().currentRoom, deafened: newDeafened },
|
data: { room_name: get().currentRoom, deafened: newDeafened },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -335,13 +335,11 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
|
|
||||||
whisperTo: (targetUserId, targetUsername, message) => {
|
whisperTo: (targetUserId, targetUsername, message) => {
|
||||||
const wsSend = useWebSocketStore.getState().send;
|
const wsSend = useWebSocketStore.getState().send;
|
||||||
const me = useAuthStore.getState().user;
|
|
||||||
wsSend({
|
wsSend({
|
||||||
type: 'VOICE_WHISPER',
|
type: 'VOICE_WHISPER',
|
||||||
payload: {
|
data: {
|
||||||
target_user_id: targetUserId,
|
target_user_id: targetUserId,
|
||||||
target_username: targetUsername,
|
target_username: targetUsername,
|
||||||
from_username: me?.username || 'unknown',
|
|
||||||
message: message || '',
|
message: message || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+62
-45
@@ -5,6 +5,7 @@ import { useServerStore } from './server.ts';
|
|||||||
import { usePresenceStore } from './presence.ts';
|
import { usePresenceStore } from './presence.ts';
|
||||||
import { useTypingStore } from './typing.ts';
|
import { useTypingStore } from './typing.ts';
|
||||||
import { useVoiceStore } from './voice.ts';
|
import { useVoiceStore } from './voice.ts';
|
||||||
|
import { useAuthStore } from './auth.ts';
|
||||||
import type { Message } from './message.ts';
|
import type { Message } from './message.ts';
|
||||||
import type { Channel } from './channel.ts';
|
import type { Channel } from './channel.ts';
|
||||||
import type { Server } from './server.ts';
|
import type { Server } from './server.ts';
|
||||||
@@ -14,7 +15,7 @@ type UnknownPayload = Record<string, unknown>;
|
|||||||
|
|
||||||
export interface WsEvent {
|
export interface WsEvent {
|
||||||
type: string;
|
type: string;
|
||||||
payload: UnknownPayload;
|
data?: UnknownPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketState {
|
interface WebSocketState {
|
||||||
@@ -25,6 +26,17 @@ interface WebSocketState {
|
|||||||
send: (event: WsEvent) => void;
|
send: (event: WsEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let unreadCount = 0;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
unreadCount = 0;
|
||||||
|
if (document.title.match(/^\(\d+\)\s/)) {
|
||||||
|
document.title = document.title.replace(/^\(\d+\)\s/, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWsHost(): string {
|
function getWsHost(): string {
|
||||||
const { protocol, host } = window.location;
|
const { protocol, host } = window.location;
|
||||||
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -35,32 +47,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null;
|
return typeof value === 'object' && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMessage(payload: UnknownPayload): Message | null {
|
function extractIds(payload: UnknownPayload | undefined): { channel_id: string; message_id: string } | null {
|
||||||
if (!isRecord(payload.message)) return null;
|
if (!payload || typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') {
|
||||||
return payload.message as unknown as Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractChannel(payload: UnknownPayload): Channel | null {
|
|
||||||
if (!isRecord(payload.channel)) return null;
|
|
||||||
return payload.channel as unknown as Channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractServer(payload: UnknownPayload): Server | null {
|
|
||||||
if (!isRecord(payload.server)) return null;
|
|
||||||
return payload.server as unknown as Server;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractIds(payload: UnknownPayload): { channel_id: string; message_id: string } | null {
|
|
||||||
if (typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { channel_id: payload.channel_id, message_id: payload.message_id };
|
return { channel_id: payload.channel_id, message_id: payload.message_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractChannelId(payload: UnknownPayload): string | null {
|
|
||||||
return typeof payload.channel_id === 'string' ? payload.channel_id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
||||||
socket: null,
|
socket: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -115,7 +108,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'ping') {
|
if (data.type === 'ping') {
|
||||||
get().send({ type: 'pong', payload: {} });
|
get().send({ type: 'pong', data: {} });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,51 +120,75 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
const removeChannel = useChannelStore.getState().removeChannel;
|
const removeChannel = useChannelStore.getState().removeChannel;
|
||||||
const updateServer = useServerStore.getState().updateServer;
|
const updateServer = useServerStore.getState().updateServer;
|
||||||
|
|
||||||
|
const payload = data.data || {};
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'message': {
|
case 'MESSAGE_CREATE': {
|
||||||
const msg = extractMessage(data.payload);
|
if (isRecord(payload)) {
|
||||||
if (msg) addMessage(msg);
|
const msg = payload as unknown as Message;
|
||||||
|
addMessage(msg);
|
||||||
|
|
||||||
|
// Desktop notification
|
||||||
|
const currentUserId = useAuthStore.getState().user?.id;
|
||||||
|
if (msg.author_id !== currentUserId && !document.hasFocus()) {
|
||||||
|
// Update browser tab title
|
||||||
|
unreadCount++;
|
||||||
|
if (document.title.match(/^\(\d+\)\s/)) {
|
||||||
|
document.title = document.title.replace(/^\(\d+\)\s/, `(${unreadCount}) `);
|
||||||
|
} else {
|
||||||
|
document.title = `(${unreadCount}) ` + document.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
const title = msg.author_username ? `New message from ${msg.author_display_name || msg.author_username}` : 'New message';
|
||||||
|
const notification = new Notification(title, {
|
||||||
|
body: msg.content,
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
});
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
notification.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'message_updated': {
|
case 'MESSAGE_UPDATE': {
|
||||||
const msg = extractMessage(data.payload);
|
if (isRecord(payload)) updateMessage(payload as unknown as Message);
|
||||||
if (msg) updateMessage(msg);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'message_deleted': {
|
case 'MESSAGE_DELETE': {
|
||||||
const ids = extractIds(data.payload);
|
const ids = extractIds(payload);
|
||||||
if (ids) removeMessage(ids.channel_id, ids.message_id);
|
if (ids) removeMessage(ids.channel_id, ids.message_id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'channel_created': {
|
case 'CHANNEL_CREATE': {
|
||||||
const channel = extractChannel(data.payload);
|
if (isRecord(payload)) addChannel(payload as unknown as Channel);
|
||||||
if (channel) addChannel(channel);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'channel_updated': {
|
case 'CHANNEL_UPDATE': {
|
||||||
const channel = extractChannel(data.payload);
|
if (isRecord(payload)) updateChannel(payload as unknown as Channel);
|
||||||
if (channel) updateChannel(channel);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'channel_deleted': {
|
case 'CHANNEL_DELETE': {
|
||||||
const channelId = extractChannelId(data.payload);
|
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : null;
|
||||||
if (channelId) removeChannel(channelId);
|
if (channelId) removeChannel(channelId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'server_updated': {
|
case 'SERVER_UPDATE': {
|
||||||
const server = extractServer(data.payload);
|
if (isRecord(payload)) updateServer(payload as unknown as Server);
|
||||||
if (server) updateServer(server);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'PRESENCE_UPDATE': {
|
case 'PRESENCE_UPDATE': {
|
||||||
const { user_id, username, status } = data.payload as Record<string, string>;
|
const { user_id, username, status } = payload as Record<string, string>;
|
||||||
if (user_id && username && status) {
|
if (user_id && username && status) {
|
||||||
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
|
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'TYPING_START': {
|
case 'TYPING_START': {
|
||||||
const { channel_id, user_id, username } = data.payload as Record<string, string>;
|
const { channel_id, user_id, username } = payload as Record<string, string>;
|
||||||
if (channel_id && user_id && username) {
|
if (channel_id && user_id && username) {
|
||||||
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
|
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
|
||||||
}
|
}
|
||||||
@@ -179,7 +196,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
case 'VOICE_WHISPER': {
|
case 'VOICE_WHISPER': {
|
||||||
// Forwarded from backend — only intended recipient gets this
|
// Forwarded from backend — only intended recipient gets this
|
||||||
const { from_user_id, from_username, message } = data.payload as Record<string, string>;
|
const { from_user_id, from_username, message } = payload as Record<string, string>;
|
||||||
useVoiceStore.getState()._addWhisper({
|
useVoiceStore.getState()._addWhisper({
|
||||||
fromUserId: from_user_id || '',
|
fromUserId: from_user_id || '',
|
||||||
fromUsername: from_username || 'unknown',
|
fromUsername: from_username || 'unknown',
|
||||||
|
|||||||
Reference in New Issue
Block a user