Compare commits

..

29 Commits

Author SHA1 Message Date
hobokenchicken 02db6c719c fix(gateway): hash session tokens in WS auth; fix WS reconnect backoff
The token-hashing commit (57aec2c) never updated ServeWS to hash tokens
before querying the sessions table. Cookie and message-frame auth both
compared raw tokens against stored hashes, so every WS connection failed.

Also moved reconnectDelay to module scope so the exponential backoff
survives across connect() calls, and resets on successful open.
2026-07-27 13:22:43 -04:00
hobokenchicken df2a992fa7 fix(web): prevent UI flash by preserving user state in fetchMe and removing CSS pulse 2026-07-27 12:06:28 -04:00
hobokenchicken 410b7a4d6b fix(gateway): resolve dead session cookie infinite rejection loop for WebSocket auth 2026-07-27 12:01:48 -04:00
hobokenchicken 5eeb659b70 fix(web): normalize channel and server IDs across channel store and auto-select default channel 2026-07-27 11:56:39 -04:00
hobokenchicken 13e3aec2d5 feat(web): add automated build version tracking and update notification banner 2026-07-27 09:51:15 -04:00
hobokenchicken 83c6badc20 fix(web): prevent date parsing sort drop to top of history for real-time messages 2026-07-27 09:46:42 -04:00
hobokenchicken 43b20c5ce3 fix(docker): use handle instead of handle_path to preserve /api/v1 prefix in Caddy proxy 2026-07-27 09:33:13 -04:00
hobokenchicken b0087e12af fix(docker): reverse proxy to host service in Caddyfile 2026-07-27 09:27:11 -04:00
hobokenchicken 1a1f2fc99c fix(deploy): sync web/dist to Docker Caddy volume in deploy.sh 2026-07-27 09:21:53 -04:00
hobokenchicken e927dd2cb0 fix(server): set no-cache headers on index.html to prevent stale SPA bundles 2026-07-27 09:16:18 -04:00
hobokenchicken beb04196ca fix(gateway): direct DB query and normalized UserID matching in BroadcastToServer 2026-07-27 09:12:35 -04:00
hobokenchicken a54a67e41b fix(web): persistent WS connection, auto-refetch on ready, and direct selector binding with scroll fix 2026-07-27 09:09:27 -04:00
hobokenchicken 86717a2867 fix(web): normalize channel IDs to lowercase across stores and WS 2026-07-27 09:05:23 -04:00
hobokenchicken 4a416427e9 fix(web): robust date parsing and merge strategy in message store 2026-07-27 09:04:49 -04:00
hobokenchicken 8261555026 fix(web): use keyed Fragment in ChatArea message list 2026-07-27 09:04:35 -04:00
hobokenchicken 3cde62bdc6 fix(web): normalize conversation IDs to lowercase and bind DMChat messages selector 2026-07-27 09:01:53 -04:00
hobokenchicken 065f036807 fix(web): navigate to newly created DM in NewConversationModal
Release Desktop Apps / build-linux (push) Failing after 11m33s
Release Desktop Apps / build-windows (push) Failing after 11m37s
Release Desktop Apps / release (push) Has been skipped
2026-07-27 08:50:14 -04:00
hobokenchicken f53cd49803 fix(web): robust date parsing and merge strategy in conversation store 2026-07-27 08:49:57 -04:00
hobokenchicken 4e48815b91 fix(web): use keyed Fragment in DMChat message mapping 2026-07-27 08:49:41 -04:00
hobokenchicken d9b3162f1c fix(web): move hasMore selector below id declaration to avoid TDZ crash in DMChat 2026-07-22 08:36:12 -04:00
hobokenchicken 6384588122 chore: bump to 0.2.10 (versionCode 2010) 2026-07-21 10:09:20 -04:00
hobokenchicken 978e94da90 fix(android): bump safe-top to 2rem, enlarge toolbar icons, fix login spacing
- --safe-top: 1.5rem → 2rem for extra status bar clearance
- Formatting toolbar: w-3→w-4, p-1→p-1.5 for better touch targets
- Login form: increased vertical spacing between buttons, text-xs on passkey
2026-07-21 09:58:02 -04:00
hobokenchicken 34c18c13ae fix(android): increase login form button spacing, shrink passkey text
- forgot password link: mt-3 → mt-4
- passkey button: mt-3 → mt-4, added text-xs to prevent overflow
- create account: mt-4 → mt-5
2026-07-21 09:45:40 -04:00
hobokenchicken cca6ea0e37 fix: stop infinite scroll feedback when no more messages
Scroll handler now checks !hasMore to avoid calling fetchOlderMessages
when all messages are loaded. Previously the handler would fire on every
scroll event (since scrollTop stayed < 100), creating a .then() callback
loop that adjusted scroll position repeatedly, causing 'stuck' scrolling.
2026-07-21 09:05:39 -04:00
hobokenchicken 738b9b17ff fix: add unsafe-inline to Tauri script-src CSP
Release Desktop Apps / build-linux (push) Successful in 4m50s
Release Desktop Apps / build-windows (push) Successful in 16m59s
Release Desktop Apps / release (push) Successful in 11s
2026-07-20 13:42:21 -04:00
hobokenchicken 44370f41e5 ops: .dockerignore, non-root user, network isolation, deploy rollback
- .dockerignore excludes .git, node_modules, certs, *.zip
- Dockerfile runs as non-root appuser
- compose.yml isolates frontend (Caddy only) and backend (DB/cache/media) networks
- deploy.sh snapshots binary before pull and rolls back on failed health check
2026-07-20 13:15:19 -04:00
hobokenchicken 1d6c9bdfe6 perf: rate limiter cleanup goroutine, DB pool constraints
- ipLimiter evicts stale entries after 10 min of inactivity via 5 min sweep
- DB pool capped at 25 max open, 5 idle, 15 min lifetime
2026-07-20 13:13:58 -04:00
hobokenchicken 57aec2c6b3 sec: hash tokens in DB, set Tauri CSP, escape quotes in XSS guard
- Session and email verification/reset tokens stored as SHA-256 hash in DB
  (raw token stays client-side in cookie/email link)
- Tauri CSP set: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:; connect-src 'self' wss: https:
- escapeHtml now handles double and single quotes to prevent attribute-based XSS
2026-07-20 13:13:09 -04:00
hobokenchicken d4ca5f576e sec: point coturn at live LE certs, remove old ./certs/ mount 2026-07-20 13:10:27 -04:00
31 changed files with 692 additions and 339 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
node_modules
web/dist
certs
*.zip
dumpster-server
.env
.env.local
+1 -1
View File
@@ -8,7 +8,7 @@ build: build-web build-server
# Build the Go server # Build the Go server
build-server: build-server:
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server CGO_ENABLED=0 go build -ldflags "-X main.GitSHA=$(GIT_SHA)" -o dumpster-server ./cmd/server
# Build the TUI client # Build the TUI client
build-tui: build-tui:
+14 -3
View File
@@ -47,9 +47,8 @@ import (
// @host localhost:8080 // @host localhost:8080
// @BasePath /api/v1 // @BasePath /api/v1
// @schemes http https // @schemes http https
// @securityDefinitions.apikey SessionAuth var GitSHA = "dev"
// @in cookie
// @name dumpster_session
func main() { func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
@@ -153,6 +152,14 @@ func main() {
// API routes // API routes
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
json.NewEncoder(w).Encode(map[string]string{
"version": GitSHA,
})
})
// Auth (public: register, login, logout) with strict rate limiting // Auth (public: register, login, logout) with strict rate limiting
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10 r.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
@@ -446,9 +453,13 @@ func main() {
// If the file exists, serve it; otherwise serve index.html (SPA fallback) // If the file exists, serve it; otherwise serve index.html (SPA fallback)
path := staticDir + r.URL.Path path := staticDir + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
http.ServeFile(w, r, staticDir+"/index.html") http.ServeFile(w, r, staticDir+"/index.html")
return return
} }
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
fileServer.ServeHTTP(w, r) fileServer.ServeHTTP(w, r)
}) })
} }
+31 -2
View File
@@ -8,8 +8,37 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
nvm use 22 nvm use 22
# Snapshot the current binary before pulling
if [ -f dumpster-server ]; then
cp dumpster-server dumpster-server.bak
echo "Backed up existing binary to dumpster-server.bak"
fi
git pull git pull
make build make build
systemctl restart dumpster
echo "Deploy complete — $(date)" # Sync web dist to Docker Caddy volume if present
if [ -d /var/lib/docker/volumes/docker_web_assets/_data ]; then
rsync -a --delete web/dist/ /var/lib/docker/volumes/docker_web_assets/_data/
echo "Synced web assets to Docker Caddy volume"
fi
# Health check after restart — roll back on failure
systemctl restart dumpster
echo "Waiting for app to become healthy..."
for i in $(seq 1 15); do
if curl -sf http://localhost:8080/ > /dev/null 2>&1; then
echo "Server is listening — deploy complete"
rm -f dumpster-server.bak
echo "Deploy complete — $(date)"
exit 0
fi
sleep 2
done
echo "Health check failed after 30s — rolling back..."
systemctl stop dumpster
cp dumpster-server.bak dumpster-server
systemctl start dumpster
echo "Rolled back to previous binary — $(date)"
exit 1
+4 -4
View File
@@ -4,14 +4,14 @@
} }
:80 { :80 {
# API routes # API routes — use handle (not handle_path) to preserve the /api/v1 prefix
handle_path /api/* { handle /api/* {
reverse_proxy app:8080 reverse_proxy 172.18.0.1:8080
} }
# WebSocket gateway # WebSocket gateway
handle /ws { handle /ws {
reverse_proxy app:8080 { reverse_proxy 172.18.0.1:8080 {
header_up Connection {>Connection} header_up Connection {>Connection}
header_up Upgrade {>Upgrade} header_up Upgrade {>Upgrade}
} }
+3 -1
View File
@@ -16,8 +16,10 @@ RUN npm run build
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
RUN adduser -D -g '' appuser
WORKDIR /app WORKDIR /app
COPY --from=go-builder /bin/dumpster-server /app/dumpster-server COPY --from=go-builder /bin/dumpster-server /app/dumpster-server
COPY --from=web-builder /app/dist /srv/web COPY --from=web-builder /app/dist /srv/web
USER appuser
EXPOSE 8080 EXPOSE 8080
CMD ["sh", "-c", "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"] CMD sh -c "if [ -z \"$DUMPSTER_SECRET\" ]; then export DUMPSTER_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32); fi; /app/dumpster-server"
+22 -3
View File
@@ -1,5 +1,9 @@
version: '3.8' version: '3.8'
networks:
frontend:
backend:
services: services:
app: app:
build: build:
@@ -32,6 +36,9 @@ services:
condition: service_healthy condition: service_healthy
valkey: valkey:
condition: service_healthy condition: service_healthy
networks:
- frontend
- backend
postgres: postgres:
image: postgres:16 image: postgres:16
@@ -49,6 +56,8 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks:
- backend
valkey: valkey:
image: valkey/valkey:8 image: valkey/valkey:8
@@ -62,6 +71,8 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 5
networks:
- backend
minio: minio:
image: minio/minio:latest image: minio/minio:latest
@@ -75,6 +86,8 @@ services:
- "9001:9001" - "9001:9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
networks:
- backend
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:latest
@@ -89,6 +102,8 @@ services:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
environment: environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-secret}"
networks:
- backend
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
@@ -99,8 +114,8 @@ services:
- "--max-port=50200" - "--max-port=50200"
- "--realm=${DUMPSTER_HOST:-localhost}" - "--realm=${DUMPSTER_HOST:-localhost}"
- "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" - "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}"
- "--cert=/etc/coturn/certs/turn.crt" - "--cert=/etc/letsencrypt/live/turn.dustin.coffee/fullchain.pem"
- "--pkey=/etc/coturn/certs/turn.key" - "--pkey=/etc/letsencrypt/live/turn.dustin.coffee/privkey.pem"
- "--tls-listening-port=5349" - "--tls-listening-port=5349"
ports: ports:
- "3478:3478/tcp" - "3478:3478/tcp"
@@ -109,7 +124,9 @@ services:
- "5349:5349/udp" - "5349:5349/udp"
- "50101-50200:50101-50200/udp" - "50101-50200:50101-50200/udp"
volumes: volumes:
- ./certs:/etc/coturn/certs:ro - /etc/letsencrypt:/etc/letsencrypt:ro
networks:
- backend
caddy: caddy:
image: caddy:2 image: caddy:2
@@ -124,6 +141,8 @@ services:
- caddy_config:/config - caddy_config:/config
depends_on: depends_on:
- app - app
networks:
- frontend
volumes: volumes:
postgres_data: postgres_data:
+6 -4
View File
@@ -30,10 +30,11 @@ func (h *Handler) RequestVerification(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours') VALUES ($1, $2, 'verify_email', NOW() + INTERVAL '24 hours')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -63,7 +64,7 @@ func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW() WHERE token = $1 AND type = 'verify_email' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
@@ -100,10 +101,11 @@ func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
} }
token := generateToken() token := generateToken()
tokenHash := hashToken(token)
_, err = h.db.ExecContext(r.Context(), ` _, err = h.db.ExecContext(r.Context(), `
INSERT INTO user_tokens (user_id, token, type, expires_at) INSERT INTO user_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour') VALUES ($1, $2, 'reset_password', NOW() + INTERVAL '1 hour')
`, userID, token) `, userID, tokenHash)
if err != nil { if err != nil {
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError) http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
return return
@@ -134,7 +136,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
DELETE FROM user_tokens DELETE FROM user_tokens
WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW() WHERE token = $1 AND type = 'reset_password' AND expires_at > NOW()
RETURNING user_id RETURNING user_id
`, req.Token).Scan(&userID) `, hashToken(req.Token)).Scan(&userID)
if err != nil { if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest) http.Error(w, `{"error":"invalid or expired token"}`, http.StatusBadRequest)
return return
+12 -3
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -20,17 +21,25 @@ func NewSessionStore(db *sql.DB, cfg *config.Config) *SessionStore {
return &SessionStore{db: db, cfg: cfg} return &SessionStore{db: db, cfg: cfg}
} }
// hashToken computes a SHA-256 hash of a token string.
// Tokens are stored hashed in the DB so a DB leak doesn't expose active sessions.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) { func (s *SessionStore) Create(ctx context.Context, userID string) (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err) return "", fmt.Errorf("generate token: %w", err)
} }
token := hex.EncodeToString(b) token := hex.EncodeToString(b)
tokenHash := hashToken(token)
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (user_id, token, expires_at) INSERT INTO sessions (user_id, token, expires_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
`, userID, token, time.Now().Add(s.cfg.Session.Duration)) `, userID, tokenHash, time.Now().Add(s.cfg.Session.Duration))
if err != nil { if err != nil {
return "", fmt.Errorf("insert session: %w", err) return "", fmt.Errorf("insert session: %w", err)
} }
@@ -43,7 +52,7 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT user_id FROM sessions SELECT user_id FROM sessions
WHERE token = $1 AND expires_at > NOW() WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID) `, hashToken(token)).Scan(&userID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -51,6 +60,6 @@ func (s *SessionStore) GetUserIDByToken(ctx context.Context, token string) (stri
} }
func (s *SessionStore) Delete(ctx context.Context, token string) error { func (s *SessionStore) Delete(ctx context.Context, token string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, token) _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token))
return err return err
} }
+6
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"time"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
@@ -24,6 +25,11 @@ func New(cfg *config.Config) (*DB, error) {
return nil, fmt.Errorf("ping database: %w", err) return nil, fmt.Errorf("ping database: %w", err)
} }
// ponytail: prevent unbounded connection growth under burst load
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(15 * time.Minute)
return &DB{db}, nil return &DB{db}, nil
} }
+43 -31
View File
@@ -263,40 +263,52 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
return return
} }
// Try cookie-based auth first (browser clients).
var token string
if cookie, cookieErr := r.Cookie(cookieName); cookieErr == nil && cookie.Value != "" {
token = cookie.Value
}
// Fall back to message-frame auth (TUI, bots).
if token == "" {
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
logger.Warn("ws auth: failed to read auth message", "error", readErr)
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"auth timeout"}`))
conn.Close()
return
}
var auth authMessage
if jsonErr := json.Unmarshal(raw, &auth); jsonErr != nil || auth.Token == "" {
logger.Warn("ws auth: invalid auth message")
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"missing token"}`))
conn.Close()
return
}
token = auth.Token
}
var userID, username string var userID, username string
err = db.QueryRowContext(context.Background(),
// Collect candidate tokens from Query param, Authorization header, and Cookies.
var candidateTokens []string
if qToken := r.URL.Query().Get("token"); qToken != "" {
candidateTokens = append(candidateTokens, qToken)
}
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
candidateTokens = append(candidateTokens, authHeader[7:])
}
for _, c := range r.Cookies() {
if c.Name == cookieName && c.Value != "" {
candidateTokens = append(candidateTokens, c.Value)
}
}
// Test candidates against Postgres sessions (tokens are stored hashed)
for _, token := range candidateTokens {
err := db.QueryRowContext(context.Background(),
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`, `SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
token, hashToken(token),
).Scan(&userID, &username) ).Scan(&userID, &username)
if err != nil { if err == nil && userID != "" {
logger.Warn("ws auth: invalid session", "error", err) break
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`)) }
}
// Fall back to message-frame auth if no candidate token authenticated (wait up to 5s)
if userID == "" {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, raw, readErr := conn.ReadMessage()
if readErr == nil {
var auth authMessage
if jsonErr := json.Unmarshal(raw, &auth); jsonErr == nil && auth.Token != "" {
_ = db.QueryRowContext(context.Background(),
`SELECT u.id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > NOW()`,
hashToken(auth.Token),
).Scan(&userID, &username)
}
}
}
if userID == "" {
logger.Warn("ws auth: no valid session found")
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","error":"invalid_session"}`))
conn.Close() conn.Close()
return return
} }
+25 -8
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -285,15 +286,30 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := h.db.QueryContext(ctx, `SELECT user_id FROM members WHERE server_id = $1`, serverID)
if err != nil {
h.logger.Error("failed to load server members", "server_id", serverID, "error", err)
return
}
defer rows.Close()
members := make(map[string]bool)
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err == nil {
members[strings.ToLower(strings.TrimSpace(uid))] = true
}
}
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
for client := range h.clients { for client := range h.clients {
servers, ok := h.userServers[client.UserID] cID := strings.ToLower(strings.TrimSpace(client.UserID))
if !ok { if members[cID] {
continue
}
if _, member := servers[serverID]; member {
select { select {
case client.send <- data: case client.send <- data:
default: default:
@@ -322,18 +338,19 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
} }
defer rows.Close() defer rows.Close()
members := make(map[string]struct{}) members := make(map[string]bool)
for rows.Next() { for rows.Next() {
var uid string var uid string
if err := rows.Scan(&uid); err == nil { if err := rows.Scan(&uid); err == nil {
members[uid] = struct{}{} members[strings.ToLower(strings.TrimSpace(uid))] = true
} }
} }
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
for client := range h.clients { for client := range h.clients {
if _, ok := members[client.UserID]; ok { cID := strings.ToLower(strings.TrimSpace(client.UserID))
if members[cID] {
select { select {
case client.send <- data: case client.send <- data:
default: default:
+25 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -12,16 +13,21 @@ import (
type ipLimiter struct { type ipLimiter struct {
mu sync.Mutex mu sync.Mutex
limiters map[string]*rate.Limiter limiters map[string]*rate.Limiter
lastUsed map[string]time.Time
rate rate.Limit rate rate.Limit
burst int burst int
} }
func newIPLimiter(r rate.Limit, burst int) *ipLimiter { func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
return &ipLimiter{ l := &ipLimiter{
limiters: make(map[string]*rate.Limiter), limiters: make(map[string]*rate.Limiter),
lastUsed: make(map[string]time.Time),
rate: r, rate: r,
burst: burst, burst: burst,
} }
// ponytail: evict inactive limiters every 5 minutes to prevent unbounded map growth
go l.cleanup()
return l
} }
func (l *ipLimiter) getLimiter(ip string) *rate.Limiter { func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
@@ -33,9 +39,27 @@ func (l *ipLimiter) getLimiter(ip string) *rate.Limiter {
lim = rate.NewLimiter(l.rate, l.burst) lim = rate.NewLimiter(l.rate, l.burst)
l.limiters[ip] = lim l.limiters[ip] = lim
} }
l.lastUsed[ip] = time.Now()
return lim return lim
} }
// cleanup runs in a background goroutine and removes limiters that haven't
// been accessed in 10 minutes.
func (l *ipLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
l.mu.Lock()
now := time.Now()
for ip, last := range l.lastUsed {
if now.Sub(last) > 10*time.Minute {
delete(l.limiters, ip)
delete(l.lastUsed, ip)
}
}
l.mu.Unlock()
}
}
// RateLimit returns middleware that limits requests per IP. // RateLimit returns middleware that limits requests per IP.
// requestsPerSecond is the sustained rate, burst is the max burst size. // requestsPerSecond is the sustained rate, burst is the max burst size.
func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler { func RateLimit(requestsPerSecond float64, burst int) func(http.Handler) http.Handler {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "dumpsterChat", "productName": "dumpsterChat",
"version": "0.2.9", "version": "0.2.10",
"identifier": "coffee.dustin.dumpster", "identifier": "coffee.dustin.dumpster",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -23,7 +23,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"
} }
}, },
"bundle": { "bundle": {
+13 -1
View File
@@ -117,9 +117,21 @@ export function ChannelList() {
}, [fetchNotifSettings, fetchReadStates]); }, [fetchNotifSettings, fetchReadStates]);
const channels = useMemo(() => { const channels = useMemo(() => {
return activeServerId ? channelsByServer[activeServerId] || [] : []; return activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
}, [activeServerId, channelsByServer]); }, [activeServerId, channelsByServer]);
useEffect(() => {
if (channels.length > 0) {
const activeExists = activeChannelId && channels.some((c) => c.id === activeChannelId);
if (!activeExists) {
const defaultChannel = channels.find((c) => c.type === 'text') || channels[0];
if (defaultChannel) {
setActiveChannel(defaultChannel.id);
}
}
}
}, [channels, activeChannelId, setActiveChannel]);
const activeServer = useMemo(() => { const activeServer = useMemo(() => {
if (!activeServerId) return null; if (!activeServerId) return null;
return servers.find((s) => s.id === activeServerId) || null; return servers.find((s) => s.id === activeServerId) || null;
+14 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react"; import { useEffect, useRef, useState, useMemo, useCallback, memo, Fragment } from "react";
import { useMessageStore } from "../stores/message.ts"; import { useMessageStore } from "../stores/message.ts";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import { useChannelStore } from "../stores/channel.ts"; import { useChannelStore } from "../stores/channel.ts";
@@ -284,14 +284,14 @@ const MessageItem = memo(({
MessageItem.displayName = "MessageItem"; MessageItem.displayName = "MessageItem";
export function ChatArea() { export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId); const rawActiveChannelId = useChannelStore((s) => s.activeChannelId);
const activeChannelId = rawActiveChannelId ? rawActiveChannelId.toLowerCase() : null;
const channelsByServer = useChannelStore((s) => s.channelsByServer); const channelsByServer = useChannelStore((s) => s.channelsByServer);
const activeServerId = useServerStore((s) => s.activeServerId); const activeServerId = useServerStore((s) => s.activeServerId);
const messages = useMessageStore((s) => const messages = useMessageStore((s) => (activeChannelId ? s.messagesByChannel[activeChannelId] || [] : []));
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
);
const isLoading = useMessageStore((s) => s.isLoading); const isLoading = useMessageStore((s) => s.isLoading);
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder); const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
const fetchMessages = useMessageStore((s) => s.fetchMessages); const fetchMessages = useMessageStore((s) => s.fetchMessages);
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages); const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
const sendMessage = useMessageStore((s) => s.sendMessage); const sendMessage = useMessageStore((s) => s.sendMessage);
@@ -332,8 +332,8 @@ export function ChatArea() {
); );
const canBulkDelete = true; const canBulkDelete = true;
const channels = activeServerId ? channelsByServer[activeServerId] || [] : []; const channels = activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
const activeChannel = channels.find((c) => c.id === activeChannelId); const activeChannel = channels.find((c) => c.id.toLowerCase() === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : []; const members = activeServerId ? membersByServer[activeServerId] || [] : [];
// Humans only for mentions / nickname lookup (bots live in member list separately). // Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]); const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
@@ -447,19 +447,21 @@ export function ChatArea() {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
if (!el || !activeChannelId || isLoadingOlder) return; if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
if (el.scrollTop < 100) { if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight; const prevHeight = el.scrollHeight;
fetchOlderMessages(activeChannelId).then(() => { fetchOlderMessages(activeChannelId).then(() => {
// ponytail: maintain scroll position after prepending older messages
requestAnimationFrame(() => { requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight - prevHeight; el.scrollTop = el.scrollHeight - prevHeight;
}); });
}); });
} }
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]); }, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
useEffect(() => { useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
bottomRef.current?.scrollIntoView({ behavior: "auto" }); bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]); }, [messages]);
@@ -846,9 +848,8 @@ export function ChatArea() {
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined; const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1; const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
return ( return (
<> <Fragment key={message.id}>
<MessageItem <MessageItem
key={message.id}
message={message} message={message}
memberUsernames={memberUsernames} memberUsernames={memberUsernames}
selectMode={selectMode} selectMode={selectMode}
@@ -873,7 +874,7 @@ export function ChatArea() {
<span className="flex-1 border-t border-gb-red"></span> <span className="flex-1 border-t border-gb-red"></span>
</div> </div>
)} )}
</> </Fragment>
); );
})} })}
<div ref={bottomRef} /> <div ref={bottomRef} />
+10 -7
View File
@@ -16,9 +16,10 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
export function ConversationList() { export function ConversationList() {
const conversations = useConversationStore((s) => s.conversations); const conversations = useConversationStore((s) => s.conversations);
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId?.toLowerCase() ?? null);
const fetchConversations = useConversationStore((s) => s.fetchConversations); const fetchConversations = useConversationStore((s) => s.fetchConversations);
const createConversation = useConversationStore((s) => s.createConversation); const createConversation = useConversationStore((s) => s.createConversation);
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
const messagesByConv = useConversationStore((s) => s.messagesByConversation); const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread); const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
@@ -31,8 +32,9 @@ export function ConversationList() {
fetchConversations(); fetchConversations();
}, [fetchConversations]); }, [fetchConversations]);
const openConversation = (convId: string) => { const openConversation = (rawId: string) => {
// mark as read when opening const convId = rawId.toLowerCase();
setActiveConversation(convId);
const msgs = messagesByConv[convId] || []; const msgs = messagesByConv[convId] || [];
if (msgs.length > 0) { if (msgs.length > 0) {
markConvRead(convId, msgs[msgs.length - 1].id); markConvRead(convId, msgs[msgs.length - 1].id);
@@ -50,7 +52,7 @@ export function ConversationList() {
try { try {
const conv = await createConversation([]); const conv = await createConversation([]);
if (conv) { if (conv) {
navigate(`/dm/${conv.id}`); openConversation(conv.id);
} }
} catch (err) { } catch (err) {
console.error("Failed to create notes:", err); console.error("Failed to create notes:", err);
@@ -59,7 +61,7 @@ export function ConversationList() {
}; };
const getLatestMessageId = (convId: string): string | undefined => { const getLatestMessageId = (convId: string): string | undefined => {
const msgs = messagesByConv[convId] || []; const msgs = messagesByConv[convId.toLowerCase()] || [];
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined; return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
}; };
@@ -89,19 +91,20 @@ export function ConversationList() {
<p className="text-gb-fg-f">[no conversations]</p> <p className="text-gb-fg-f">[no conversations]</p>
)} )}
{conversations.map((conv) => { {conversations.map((conv) => {
const convId = conv.id.toLowerCase();
const self = isSelfDM(conv, currentUser?.id || ""); const self = isSelfDM(conv, currentUser?.id || "");
const name = self const name = self
? "Notes" ? "Notes"
: conv.type === "group_dm" : conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ") ? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || ""); : otherMemberName(conv, currentUser?.id || "");
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id)); const unread = hasConvUnread(convId, getLatestMessageId(convId));
return ( return (
<button <button
key={conv.id} key={conv.id}
onClick={() => openConversation(conv.id)} onClick={() => openConversation(conv.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${ className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId convId === activeId
? "terminal-active" ? "terminal-active"
: "hover:bg-gb-bg-t text-gb-fg-s" : "hover:bg-gb-bg-t text-gb-fg-s"
}`} }`}
+18 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback, memo } from "react"; import { useEffect, useRef, useState, useCallback, memo, Fragment } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts"; import { useAuthStore } from "../stores/auth.ts";
@@ -152,7 +152,6 @@ export function DMChat() {
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation); const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations); const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages); const fetchMessages = useConversationStore((s) => s.fetchMessages);
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages); const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder); const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
@@ -173,8 +172,18 @@ export function DMChat() {
const markConvRead = useReadStatesStore((s) => s.markConvRead); const markConvRead = useReadStatesStore((s) => s.markConvRead);
const convStates = useReadStatesStore((s) => s.convStates); const convStates = useReadStatesStore((s) => s.convStates);
const id = conversationId || activeId; const rawId = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id); const id = rawId ? rawId.toLowerCase() : undefined;
const conversation = conversations.find((c) => c.id.toLowerCase() === id);
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
const messages = useConversationStore((s) => (id ? s.messagesByConversation[id] || [] : []));
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => { const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
if (!id) return; if (!id) return;
@@ -189,7 +198,7 @@ export function DMChat() {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
if (!el || !id || isLoadingOlder) return; if (!el || !id || isLoadingOlder || !hasMore) return;
if (el.scrollTop < 100) { if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight; const prevHeight = el.scrollHeight;
fetchOlderMessages(id).then(() => { fetchOlderMessages(id).then(() => {
@@ -198,8 +207,7 @@ export function DMChat() {
}); });
}); });
} }
}, [id, isLoadingOlder, fetchOlderMessages]); }, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
const messages = id ? messagesByConv[id] || [] : [];
useEffect(() => { useEffect(() => {
fetchConversations(); fetchConversations();
@@ -213,9 +221,7 @@ export function DMChat() {
} }
}, [id, setActive, fetchMessages]); }, [id, setActive, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => { useEffect(() => {
if (!id || messages.length === 0 || isLoading) return; if (!id || messages.length === 0 || isLoading) return;
@@ -329,9 +335,8 @@ export function DMChat() {
const lastReadId = id ? convStates[id] : undefined; const lastReadId = id ? convStates[id] : undefined;
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1; const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
return ( return (
<> <Fragment key={msg.id}>
<DMMessageItem <DMMessageItem
key={msg.id}
msg={msg} msg={msg}
onAddReaction={handleAddReaction} onAddReaction={handleAddReaction}
activeReactionMessageId={activeReactionMessageId} activeReactionMessageId={activeReactionMessageId}
@@ -348,7 +353,7 @@ export function DMChat() {
<span className="flex-1 border-t border-gb-red"></span> <span className="flex-1 border-t border-gb-red"></span>
</div> </div>
)} )}
</> </Fragment>
); );
})} })}
<div ref={bottomRef} /> <div ref={bottomRef} />
+4 -5
View File
@@ -15,6 +15,7 @@ import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx'; import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx'; import { ServerSettingsModal } from './ServerSettingsModal.tsx';
import { ThemeToggle } from './ThemeToggle.tsx'; import { ThemeToggle } from './ThemeToggle.tsx';
import { VersionNotifier } from './VersionNotifier.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline']; const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string { function statusColor(status: UserStatus): string {
@@ -35,8 +36,6 @@ export function Layout() {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
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 wsDisconnect = useWebSocketStore((s) => s.disconnect);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false); const [showStatusMenu, setShowStatusMenu] = useState(false);
@@ -63,9 +62,8 @@ export function Layout() {
}, [currentVoiceRoom]); }, [currentVoiceRoom]);
useEffect(() => { useEffect(() => {
wsConnect(); useWebSocketStore.getState().connect();
return () => { wsDisconnect(); }; }, []);
}, [wsConnect, wsDisconnect]);
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') { if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
@@ -102,6 +100,7 @@ export function Layout() {
return ( return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col"> <div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
<VersionNotifier />
{/* Outer terminal frame with safe area */} {/* Outer terminal frame with safe area */}
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0" <div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0"
style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}> style={{ padding: 'var(--safe-top, 0.25rem) var(--safe-right, 0) var(--safe-bottom, 0) var(--safe-left, 0)' }}>
+4 -4
View File
@@ -117,7 +117,7 @@ export function LoginForm() {
</button> </button>
</form> </form>
{!isRegister && ( {!isRegister && (
<div className="mt-3 text-center"> <div className="mt-4 text-center">
<Link <Link
to="/forgot-password" to="/forgot-password"
className="text-gb-yellow hover:text-gb-orange text-xs" className="text-gb-yellow hover:text-gb-orange text-xs"
@@ -127,7 +127,7 @@ export function LoginForm() {
</div> </div>
)} )}
{!isRegister && ( {!isRegister && (
<div className="mt-3"> <div className="mt-4">
<button <button
type="button" type="button"
onClick={async () => { onClick={async () => {
@@ -157,13 +157,13 @@ export function LoginForm() {
console.error("Passkey login failed:", err); console.error("Passkey login failed:", err);
} }
}} }}
className="terminal-button w-full border-gb-aqua text-gb-aqua" className="terminal-button w-full border-gb-aqua text-gb-aqua text-xs"
> >
[SIGN IN WITH PASSKEY] [SIGN IN WITH PASSKEY]
</button> </button>
</div> </div>
)} )}
<div className="mt-4 text-center"> <div className="mt-5 text-center">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
+26 -26
View File
@@ -155,7 +155,7 @@ function markdownToHtml(md: string): string {
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
} }
function inlineMarkdownToHtml(text: string): string { function inlineMarkdownToHtml(text: string): string {
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
<button type="button" disabled={disabled || uploading} <button type="button" disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="Upload file" title="Upload file"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} /> <FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
</button> </button>
<span className="text-gb-bg-t mx-1">│</span> <span className="text-gb-bg-t mx-1">│</span>
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
{/* block formatting */} {/* block formatting */}
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execBlock("ul")} title="Unordered list" onClick={() => execBlock("ul")} title="Unordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" /> <FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execBlock("ol")} title="Ordered list" onClick={() => execBlock("ol")} title="Ordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" /> <FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execBlock("blockquote")} title="Blockquote" onClick={() => execBlock("blockquote")} title="Blockquote"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" /> <FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={execLink} title="Insert link" onClick={execLink} title="Insert link"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faLink} className="w-3 h-3" /> <FontAwesomeIcon icon={faLink} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execBlock("h2")} title="Heading" onClick={() => execBlock("h2")} title="Heading"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" /> <FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
</button> </button>
<span className="text-gb-bg-t mx-1">│</span> <span className="text-gb-bg-t mx-1">│</span>
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
{/* inline formatting */} {/* inline formatting */}
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execInline("**", "**")} title="Bold" onClick={() => execInline("**", "**")} title="Bold"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faBold} className="w-3 h-3" /> <FontAwesomeIcon icon={faBold} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execInline("*", "*")} title="Italic" onClick={() => execInline("*", "*")} title="Italic"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" /> <FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execInline("~~", "~~")} title="Strikethrough" onClick={() => execInline("~~", "~~")} title="Strikethrough"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" /> <FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execInline("`", "`")} title="Code" onClick={() => execInline("`", "`")} title="Code"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faCode} className="w-3 h-3" /> <FontAwesomeIcon icon={faCode} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} <button type="button" disabled={disabled}
onClick={() => execInline("||", "||")} title="Spoiler" onClick={() => execInline("||", "||")} title="Spoiler"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50"> className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" /> <FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
</button> </button>
<span className="text-gb-bg-t mx-1">│</span> <span className="text-gb-bg-t mx-1">│</span>
{/* emoji / kaomoji / gif */} {/* emoji / kaomoji / gif */}
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji" <button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}> className={`p-1.5 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" /> <FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji" <button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}> className={`p-1.5 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" /> <FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
</button> </button>
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF" <button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}> className={`p-1.5 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" /> <FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
</button> </button>
+10 -1
View File
@@ -1,4 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useConversationStore } from "../stores/conversation.ts"; import { useConversationStore } from "../stores/conversation.ts";
import { useMemberStore } from "../stores/member.ts"; import { useMemberStore } from "../stores/member.ts";
import { useServerStore } from "../stores/server.ts"; import { useServerStore } from "../stores/server.ts";
@@ -11,6 +12,7 @@ interface NewConversationModalProps {
export function NewConversationModal({ onClose }: NewConversationModalProps) { export function NewConversationModal({ onClose }: NewConversationModalProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const navigate = useNavigate();
const createConversation = useConversationStore((s) => s.createConversation); const createConversation = useConversationStore((s) => s.createConversation);
const activeServerId = useServerStore((s) => s.activeServerId); const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer); const membersByServer = useMemberStore((s) => s.membersByServer);
@@ -48,7 +50,14 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
const handleCreate = async () => { const handleCreate = async () => {
if (selected.length === 0) return; if (selected.length === 0) return;
await createConversation(selected); try {
const conv = await createConversation(selected);
if (conv?.id) {
navigate(`/dm/${conv.id}`);
}
} catch (err) {
console.error("Failed to create conversation:", err);
}
onClose(); onClose();
}; };
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { api } from "../lib/api.ts";
import { useWebSocketStore } from "../stores/ws.ts";
export function VersionNotifier() {
const [serverVersion, setServerVersion] = useState<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
const connected = useWebSocketStore((s) => s.connected);
const checkVersion = async () => {
try {
const data = await api.get<{ version: string }>("/version");
if (data && data.version && data.version !== "dev") {
setServerVersion((prev) => {
if (!prev) {
return data.version;
}
if (prev !== data.version) {
setUpdateAvailable(true);
}
return data.version;
});
}
} catch {
// ignore check errors
}
};
useEffect(() => {
checkVersion();
const interval = setInterval(checkVersion, 2 * 60 * 1000); // Check every 2 mins
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (connected) {
checkVersion();
}
}, [connected]);
const handleReload = () => {
window.location.reload();
};
if (!updateAvailable) return null;
return (
<div className="bg-gb-accent text-gb-bg-h px-4 py-2 text-xs font-semibold flex items-center justify-between shadow-lg z-50">
<span>🚀 A new update is available! ({serverVersion})</span>
<button
onClick={handleReload}
className="bg-gb-bg-h text-gb-accent px-3 py-1 rounded hover:opacity-90 font-bold transition-all"
>
Update Now
</button>
</div>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ if (isTauri) {
// WebView safe-area fallbacks: Android doesn't support CSS env(), and // WebView safe-area fallbacks: Android doesn't support CSS env(), and
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS. // Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
if (navigator.userAgent.includes('Android')) { if (navigator.userAgent.includes('Android')) {
document.documentElement.style.setProperty('--safe-top', '1.5rem'); document.documentElement.style.setProperty('--safe-top', '2rem');
document.documentElement.style.setProperty('--safe-bottom', '0.75rem'); document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
document.documentElement.style.setProperty('--safe-left', '0px'); document.documentElement.style.setProperty('--safe-left', '0px');
document.documentElement.style.setProperty('--safe-right', '0px'); document.documentElement.style.setProperty('--safe-right', '0px');
+3 -1
View File
@@ -84,7 +84,7 @@ function autoSubscribePush() {
}); });
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
@@ -146,7 +146,9 @@ export const useAuthStore = create<AuthState>((set) => ({
}, },
fetchMe: async () => { fetchMe: async () => {
if (!get().user) {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
}
try { try {
const user = await api.get<User>(`/auth/me?t=${Date.now()}`); const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
set({ user, isAuthenticated: true, isLoading: false }); set({ user, isAuthenticated: true, isLoading: false });
+41 -18
View File
@@ -33,11 +33,18 @@ export const useChannelStore = create<ChannelState>((set) => ({
error: null, error: null,
fetchChannels: async (serverId) => { fetchChannels: async (serverId) => {
const srvId = serverId.toLowerCase();
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`); const channels = await api.get<Channel[]>(`/servers/${srvId}/channels`);
const list = Array.isArray(channels) ? channels : [];
const normalized = list.map((c) => ({
...c,
id: c.id.toLowerCase(),
server_id: c.server_id.toLowerCase(),
}));
set((state) => ({ set((state) => ({
channelsByServer: { ...state.channelsByServer, [serverId]: channels }, channelsByServer: { ...state.channelsByServer, [srvId]: normalized },
isLoading: false, isLoading: false,
})); }));
} catch (error) { } catch (error) {
@@ -49,50 +56,66 @@ export const useChannelStore = create<ChannelState>((set) => ({
}, },
setActiveChannel: (id) => { setActiveChannel: (id) => {
set({ activeChannelId: id }); const chId = id ? id.toLowerCase() : null;
if (id) { set({ activeChannelId: chId });
if (chId) {
// ponytail: persist last active channel for session restore // ponytail: persist last active channel for session restore
const state = useChannelStore.getState(); const state = useChannelStore.getState();
for (const [serverId, channels] of Object.entries(state.channelsByServer)) { for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
if (channels.some((c) => c.id === id)) { if (channels.some((c) => c.id.toLowerCase() === chId)) {
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: id })); localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: chId }));
break; break;
} }
} }
} }
}, },
addChannel: (channel) => addChannel: (channel) => {
const norm = {
...channel,
id: channel.id.toLowerCase(),
server_id: channel.server_id.toLowerCase(),
};
set((state) => { set((state) => {
const list = state.channelsByServer[channel.server_id] || []; const list = state.channelsByServer[norm.server_id] || [];
if (list.some((c) => c.id === norm.id)) return state;
return { return {
channelsByServer: { channelsByServer: {
...state.channelsByServer, ...state.channelsByServer,
[channel.server_id]: [...list, channel], [norm.server_id]: [...list, norm],
}, },
}; };
}), });
},
updateChannel: (channel) => updateChannel: (channel) => {
const norm = {
...channel,
id: channel.id.toLowerCase(),
server_id: channel.server_id.toLowerCase(),
};
set((state) => { set((state) => {
const list = state.channelsByServer[channel.server_id] || []; const list = state.channelsByServer[norm.server_id] || [];
return { return {
channelsByServer: { channelsByServer: {
...state.channelsByServer, ...state.channelsByServer,
[channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)), [norm.server_id]: list.map((c) => (c.id === norm.id ? norm : c)),
}, },
}; };
}), });
},
removeChannel: (id) => removeChannel: (id) => {
const chId = id.toLowerCase();
set((state) => { set((state) => {
const next: Record<string, Channel[]> = {}; const next: Record<string, Channel[]> = {};
for (const serverId of Object.keys(state.channelsByServer)) { for (const serverId of Object.keys(state.channelsByServer)) {
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id); next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== chId);
} }
return { return {
channelsByServer: next, channelsByServer: next,
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId, activeChannelId: state.activeChannelId === chId ? null : state.activeChannelId,
}; };
}), });
},
})); }));
+69 -35
View File
@@ -2,6 +2,13 @@ import { create } from "zustand";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import { type Reaction } from "./message.ts"; import { type Reaction } from "./message.ts";
function parseDate(iso: string): number {
if (!iso) return Date.now();
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
const t = new Date(normalized).getTime();
return isNaN(t) ? Date.now() : t;
}
export interface ConversationMember { export interface ConversationMember {
id: string; id: string;
username: string; username: string;
@@ -72,121 +79,147 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
createConversation: async (userIds) => { createConversation: async (userIds) => {
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds }); const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
const convId = conversation.id.toLowerCase();
const normalizedConv = { ...conversation, id: convId };
set((state) => ({ set((state) => ({
conversations: [conversation, ...state.conversations], conversations: [normalizedConv, ...state.conversations],
activeConversationId: conversation.id, activeConversationId: convId,
})); }));
return conversation; return normalizedConv;
}, },
setActiveConversation: (id) => set({ activeConversationId: id }), setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
fetchMessages: async (conversationId, before) => { fetchMessages: async (conversationId, before) => {
const convId = conversationId.toLowerCase();
set({ isLoading: true }); set({ isLoading: true });
try { try {
const params = before ? "?before=" + encodeURIComponent(before) : ""; const params = before ? "?before=" + encodeURIComponent(before) : "";
const messages = await api.get<ConversationMessage[]>( const messages = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages${params}`, `/conversations/${convId}/messages${params}`,
); );
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
set((state) => ({ set((state) => {
const existing = state.messagesByConversation[convId] || [];
const map = new Map<string, ConversationMessage>();
existing.forEach((m) => map.set(m.id, m));
list.forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
);
return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: list, [convId]: merged,
}, },
hasMoreByConversation: { hasMoreByConversation: {
...state.hasMoreByConversation, ...state.hasMoreByConversation,
[conversationId]: list.length >= 50, [convId]: list.length >= 50,
}, },
isLoading: false, isLoading: false,
})); };
});
} catch (error) { } catch (error) {
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" }); set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
} }
}, },
fetchOlderMessages: async (conversationId) => { fetchOlderMessages: async (conversationId) => {
const convId = conversationId.toLowerCase();
const state = get(); const state = get();
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return; if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
const existing = state.messagesByConversation[conversationId] || []; const existing = state.messagesByConversation[convId] || [];
if (existing.length === 0) return; if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id; const oldestId = existing[0].id;
set({ isLoadingOlder: true }); set({ isLoadingOlder: true });
try { try {
const older = await api.get<ConversationMessage[]>( const older = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`, `/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
); );
const list = Array.isArray(older) ? older : []; const list = Array.isArray(older) ? older : [];
set((state) => ({ set((state) => {
const map = new Map<string, ConversationMessage>();
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
);
return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: [...list, ...existing], [convId]: merged,
}, },
hasMoreByConversation: { hasMoreByConversation: {
...state.hasMoreByConversation, ...state.hasMoreByConversation,
[conversationId]: list.length >= 50, [convId]: list.length >= 50,
}, },
isLoadingOlder: false, isLoadingOlder: false,
})); };
});
} catch { } catch {
set({ isLoadingOlder: false }); set({ isLoadingOlder: false });
} }
}, },
sendMessage: async (conversationId, content) => { sendMessage: async (conversationId, content) => {
// Add locally so the message appears immediately even if WS lags. const convId = conversationId.toLowerCase();
// addMessage dedupes, so a late WS event won't double it.
const message = await api.post<ConversationMessage>( const message = await api.post<ConversationMessage>(
`/conversations/${conversationId}/messages`, `/conversations/${convId}/messages`,
{ content }, { content },
); );
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
get().addMessage(message); get().addMessage(normalizedMessage);
return message; return normalizedMessage;
}, },
addMessage: (message) => { addMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => { set((state) => {
const existing = state.messagesByConversation[message.conversation_id] || []; const existing = state.messagesByConversation[convId] || [];
if (existing.some((m) => m.id === message.id)) { if (existing.some((m) => m.id === normalizedMessage.id)) {
return state; return state;
} }
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[message.conversation_id]: [...existing, message] [convId]: [...existing, normalizedMessage]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()), .sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
}, },
}; };
}); });
}, },
updateMessage: (message) => { updateMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => { set((state) => {
const convMsgs = state.messagesByConversation[message.conversation_id] || []; const convMsgs = state.messagesByConversation[convId] || [];
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)), [convId]: convMsgs.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)),
}, },
}; };
}); });
}, },
deleteMessage: (conversationId, messageId) => { deleteMessage: (conversationId, messageId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const convMsgs = state.messagesByConversation[conversationId] || []; const convMsgs = state.messagesByConversation[convId] || [];
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: convMsgs.filter((m) => m.id !== messageId), [convId]: convMsgs.filter((m) => m.id !== messageId),
}, },
}; };
}); });
}, },
addReaction: (conversationId, messageId, emoji, userId) => { addReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByConversation[conversationId]; const messages = state.messagesByConversation[convId];
if (!messages) return state; if (!messages) return state;
const newMessages = messages.map((m) => { const newMessages = messages.map((m) => {
@@ -210,15 +243,16 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: newMessages, [convId]: newMessages,
}, },
}; };
}); });
}, },
removeReaction: (conversationId, messageId, emoji, userId) => { removeReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByConversation[conversationId]; const messages = state.messagesByConversation[convId];
if (!messages) return state; if (!messages) return state;
const newMessages = messages.map((m) => { const newMessages = messages.map((m) => {
@@ -241,7 +275,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: newMessages, [convId]: newMessages,
}, },
}; };
}); });
+152 -100
View File
@@ -1,6 +1,13 @@
import { create } from "zustand"; import { create } from "zustand";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
function parseDate(iso: string): number {
if (!iso) return Date.now();
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
const t = new Date(normalized).getTime();
return isNaN(t) ? Date.now() : t;
}
export interface MessageEmbed { export interface MessageEmbed {
id?: string; id?: string;
url: string; url: string;
@@ -59,7 +66,7 @@ export interface MessageState {
messagesByChannel: Record<string, Message[]>; messagesByChannel: Record<string, Message[]>;
pinnedMessagesByChannel: Record<string, Message[]>; pinnedMessagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>; searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection selectedMessageIds: Record<string, Set<string>>;
isLoading: boolean; isLoading: boolean;
isLoadingOlder: boolean; isLoadingOlder: boolean;
hasMoreByChannel: Record<string, boolean>; hasMoreByChannel: Record<string, boolean>;
@@ -95,24 +102,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
error: null, error: null,
fetchMessages: async (channelId, before) => { fetchMessages: async (channelId, before) => {
const chId = channelId.toLowerCase();
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const params = before ? `?before=${encodeURIComponent(before)}` : ""; const params = before ? `?before=${encodeURIComponent(before)}` : "";
const messages = await api.get<Message[]>( const messages = await api.get<Message[]>(
`/channels/${channelId}/messages${params}`, `/channels/${chId}/messages${params}`,
); );
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
set((state) => ({ set((state) => {
const existing = state.messagesByChannel[chId] || [];
const map = new Map<string, Message>();
existing.forEach((m) => map.set(m.id, m));
list.forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
);
return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list, [chId]: merged,
}, },
hasMoreByChannel: { hasMoreByChannel: {
...state.hasMoreByChannel, ...state.hasMoreByChannel,
[channelId]: list.length >= 50, [chId]: list.length >= 50,
}, },
isLoading: false, isLoading: false,
})); };
});
} catch (error) { } catch (error) {
set({ set({
isLoading: false, isLoading: false,
@@ -125,116 +142,136 @@ export const useMessageStore = create<MessageState>((set, get) => ({
}, },
fetchOlderMessages: async (channelId) => { fetchOlderMessages: async (channelId) => {
const chId = channelId.toLowerCase();
const state = get(); const state = get();
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return; if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
const existing = state.messagesByChannel[channelId] || []; const existing = state.messagesByChannel[chId] || [];
if (existing.length === 0) return; if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id; const oldestId = existing[0].id;
set({ isLoadingOlder: true }); set({ isLoadingOlder: true });
try { try {
const older = await api.get<Message[]>( const older = await api.get<Message[]>(
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`, `/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
); );
const list = Array.isArray(older) ? older : []; const list = Array.isArray(older) ? older : [];
set((state) => ({ set((state) => {
const map = new Map<string, Message>();
[...list, ...existing].forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
);
return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: [...list, ...existing], [chId]: merged,
}, },
hasMoreByChannel: { hasMoreByChannel: {
...state.hasMoreByChannel, ...state.hasMoreByChannel,
[channelId]: list.length >= 50, [chId]: list.length >= 50,
}, },
isLoadingOlder: false, isLoadingOlder: false,
})); };
});
} catch { } catch {
set({ isLoadingOlder: false }); set({ isLoadingOlder: false });
} }
}, },
searchMessages: async (channelId, query) => { searchMessages: async (channelId, query) => {
const chId = channelId.toLowerCase();
const results = await api.get<SearchResultMessage[]>( const results = await api.get<SearchResultMessage[]>(
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`, `/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`,
); );
const list = Array.isArray(results) ? results : [];
set((state) => ({ set((state) => ({
searchResultsByChannel: { searchResultsByChannel: {
...state.searchResultsByChannel, ...state.searchResultsByChannel,
[channelId]: Array.isArray(results) ? results : [], [chId]: list,
},
}));
return Array.isArray(results) ? results : [];
},
sendMessage: async (channelId, content, replyTo) => {
const body: { content: string; reply_to?: string } = { content };
if (replyTo) body.reply_to = replyTo;
// Add locally so the message appears immediately even if WS lags.
// addMessage dedupes, so a late WS event won't double it.
const message = await api.post<Message>(
`/channels/${channelId}/messages`,
body,
);
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
get().addMessage(message);
return message;
},
pinMessage: async (channelId, messageId) => {
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
get().updateMessage(updated);
},
unpinMessage: async (channelId, messageId) => {
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
get().updateMessage(updated);
},
fetchPinnedMessages: async (channelId) => {
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
const list = Array.isArray(pinned) ? pinned : [];
set((state) => ({
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: list,
}, },
})); }));
return list; return list;
}, },
addMessage: (message) => sendMessage: async (channelId, content, replyTo) => {
const chId = channelId.toLowerCase();
const body: { content: string; reply_to?: string } = { content };
if (replyTo) body.reply_to = replyTo;
const message = await api.post<Message>(
`/channels/${chId}/messages`,
body,
);
const normalizedMessage = { ...message, channel_id: (message.channel_id || chId).toLowerCase() };
get().addMessage(normalizedMessage);
return normalizedMessage;
},
pinMessage: async (channelId, messageId) => {
const chId = channelId.toLowerCase();
const updated = await api.put<Message>(`/channels/${chId}/messages/${messageId}/pin`, {});
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
get().updateMessage(normalizedMessage);
},
unpinMessage: async (channelId, messageId) => {
const chId = channelId.toLowerCase();
const updated = await api.delete<Message>(`/channels/${chId}/messages/${messageId}/pin`);
const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() };
get().updateMessage(normalizedMessage);
},
fetchPinnedMessages: async (channelId) => {
const chId = channelId.toLowerCase();
const pinned = await api.get<Message[]>(`/channels/${chId}/messages/pinned`);
const list = Array.isArray(pinned) ? pinned : [];
set((state) => ({
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[chId]: list,
},
}));
return list;
},
addMessage: (message) => {
const chId = (message.channel_id || "").toLowerCase();
const normalizedMessage = { ...message, channel_id: chId };
set((state) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[chId] || [];
if (list.some((m) => m.id === message.id)) { if (list.some((m) => m.id === normalizedMessage.id)) {
return state; return state;
} }
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[message.channel_id]: [...list, message], [chId]: [...list, normalizedMessage].sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
),
}, },
}; };
}), });
},
updateMessage: (message) => updateMessage: (message) => {
const chId = (message.channel_id || "").toLowerCase();
const normalizedMessage = { ...message, channel_id: chId };
set((state) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => const updatedList = list.map((m) =>
m.id === message.id ? message : m, m.id === normalizedMessage.id ? normalizedMessage : m,
); );
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || []; const pinnedList = state.pinnedMessagesByChannel[chId] || [];
let updatedPinned = [...pinnedList]; let updatedPinned = [...pinnedList];
if (message.pinned) { if (normalizedMessage.pinned) {
if (!pinnedList.some((m) => m.id === message.id)) { if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
updatedPinned = [message, ...pinnedList].sort( updatedPinned = [normalizedMessage, ...pinnedList].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() (a, b) => parseDate(b.created_at) - parseDate(a.created_at)
); );
} else { } else {
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m)); updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
} }
} else { } else {
updatedPinned = pinnedList.filter((m) => m.id !== message.id); updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
} }
if (updatedPinned.length > 5) { if (updatedPinned.length > 5) {
updatedPinned = updatedPinned.slice(0, 5); updatedPinned = updatedPinned.slice(0, 5);
@@ -243,34 +280,38 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[message.channel_id]: updatedList, [chId]: updatedList,
}, },
pinnedMessagesByChannel: { pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel, ...state.pinnedMessagesByChannel,
[message.channel_id]: updatedPinned, [chId]: updatedPinned,
}, },
}; };
}), });
},
removeMessage: (channelId, messageId) => removeMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const pinnedList = state.pinnedMessagesByChannel[channelId] || []; const pinnedList = state.pinnedMessagesByChannel[chId] || [];
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId), [chId]: list.filter((m) => m.id !== messageId),
}, },
pinnedMessagesByChannel: { pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel, ...state.pinnedMessagesByChannel,
[channelId]: pinnedList.filter((m) => m.id !== messageId), [chId]: pinnedList.filter((m) => m.id !== messageId),
}, },
}; };
}), });
},
addReaction: (channelId, messageId, emoji, userId) => addReaction: (channelId, messageId, emoji, userId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => { const updatedList = list.map((m) => {
if (m.id !== messageId) return m; if (m.id !== messageId) return m;
const reactions = m.reactions ? [...m.reactions] : []; const reactions = m.reactions ? [...m.reactions] : [];
@@ -288,14 +329,16 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updatedList, [chId]: updatedList,
}, },
}; };
}), });
},
removeReaction: (channelId, messageId, emoji, userId) => removeReaction: (channelId, messageId, emoji, userId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => { const updatedList = list.map((m) => {
if (m.id !== messageId) return m; if (m.id !== messageId) return m;
if (!m.reactions) return m; if (!m.reactions) return m;
@@ -311,25 +354,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updatedList, [chId]: updatedList,
}, },
}; };
}), });
},
bulkDeleteMessages: async (channelId, messageIds) => { bulkDeleteMessages: async (channelId, messageIds) => {
const chId = channelId.toLowerCase();
const res = await api.post<{ deleted: number }>( const res = await api.post<{ deleted: number }>(
`/channels/${channelId}/messages/bulk-delete`, `/channels/${chId}/messages/bulk-delete`,
{ messages: messageIds }, { messages: messageIds },
); );
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const ids = new Set(messageIds); const ids = new Set(messageIds);
const selected = { ...state.selectedMessageIds }; const selected = { ...state.selectedMessageIds };
delete selected[channelId]; delete selected[chId];
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list.filter((m) => !ids.has(m.id)), [chId]: list.filter((m) => !ids.has(m.id)),
}, },
selectedMessageIds: selected, selectedMessageIds: selected,
}; };
@@ -337,9 +382,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return res; return res;
}, },
toggleSelectedMessage: (channelId, messageId) => toggleSelectedMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const current = state.selectedMessageIds[channelId] || new Set<string>(); const current = state.selectedMessageIds[chId] || new Set<string>();
const next = new Set(current); const next = new Set(current);
if (next.has(messageId)) { if (next.has(messageId)) {
next.delete(messageId); next.delete(messageId);
@@ -349,21 +395,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
selectedMessageIds: { selectedMessageIds: {
...state.selectedMessageIds, ...state.selectedMessageIds,
[channelId]: next, [chId]: next,
}, },
}; };
}), });
},
clearSelectedMessages: (channelId) => clearSelectedMessages: (channelId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const next = { ...state.selectedMessageIds }; const next = { ...state.selectedMessageIds };
delete next[channelId]; delete next[chId];
return { selectedMessageIds: next }; return { selectedMessageIds: next };
}), });
},
createPoll: async (channelId, question, options) => { createPoll: async (channelId, question, options) => {
const chId = channelId.toLowerCase();
const resp = await api.post<Poll>("/polls", { const resp = await api.post<Poll>("/polls", {
channel_id: channelId, channel_id: chId,
question, question,
options, options,
}); });
@@ -374,9 +424,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
await api.post(`/polls/${pollId}/vote`, { option_id: optionId }); await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
}, },
updatePoll: (channelId, poll) => updatePoll: (channelId, poll) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByChannel[channelId]; const messages = state.messagesByChannel[chId];
if (!messages) return state; if (!messages) return state;
const updated = messages.map((m) => const updated = messages.map((m) =>
m.poll?.id === poll.id ? { ...m, poll } : m, m.poll?.id === poll.id ? { ...m, poll } : m,
@@ -384,8 +435,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updated, [chId]: updated,
}, },
}; };
}), });
},
})); }));
+9 -5
View File
@@ -27,25 +27,28 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
}, },
markRead: async (channelId: string, messageId: string) => { markRead: async (channelId: string, messageId: string) => {
const chId = channelId.toLowerCase();
set((state) => ({ set((state) => ({
states: { ...state.states, [channelId]: messageId }, states: { ...state.states, [chId]: messageId },
})); }));
try { try {
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId }); await api.put(`/channels/${chId}/read`, { last_read_message_id: messageId });
} catch { } catch {
// optimistic update, ignore failure // optimistic update, ignore failure
} }
}, },
markConvRead: (conversationId: string, messageId: string) => { markConvRead: (conversationId: string, messageId: string) => {
const convId = conversationId.toLowerCase();
set((state) => ({ set((state) => ({
convStates: { ...state.convStates, [conversationId]: messageId }, convStates: { ...state.convStates, [convId]: messageId },
})); }));
}, },
hasUnread: (channelId: string, latestMessageId?: string): boolean => { hasUnread: (channelId: string, latestMessageId?: string): boolean => {
const chId = channelId.toLowerCase();
const state = get().states; const state = get().states;
const lastRead = state[channelId]; const lastRead = state[chId];
// never viewed: unread if there are messages // never viewed: unread if there are messages
if (!lastRead) return !!latestMessageId; if (!lastRead) return !!latestMessageId;
// viewed but newer messages exist // viewed but newer messages exist
@@ -54,8 +57,9 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
}, },
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => { hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
const convId = conversationId.toLowerCase();
const state = get().convStates; const state = get().convStates;
const lastRead = state[conversationId]; const lastRead = state[convId];
if (!lastRead) return !!latestMessageId; if (!lastRead) return !!latestMessageId;
if (latestMessageId && lastRead !== latestMessageId) return true; if (latestMessageId && lastRead !== latestMessageId) return true;
return false; return false;
+26 -14
View File
@@ -61,11 +61,15 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
? payload.id ? payload.id
: null; : null;
if (!messageId) return null; if (!messageId) return null;
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId }; if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id.toLowerCase(), message_id: messageId };
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId }; if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id.toLowerCase(), message_id: messageId };
return null; return null;
} }
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
export const useWebSocketStore = create<WebSocketState>((set, get) => ({ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null, socket: null,
connected: false, connected: false,
@@ -78,10 +82,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
const socket = new WebSocket(`${getWsHost()}/ws`); const socket = new WebSocket(`${getWsHost()}/ws`);
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
const scheduleReconnect = () => { const scheduleReconnect = () => {
if (reconnectTimeout) { if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout); window.clearTimeout(reconnectTimeout);
@@ -95,8 +95,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}; };
socket.onopen = () => { socket.onopen = () => {
// Cookie-based auth: browser sends session cookie automatically. reconnectDelay = 1000; // ponytail: reset backoff on successful connect
// No need to send a token frame. const token = localStorage.getItem('dumpster_session_token');
if (token) {
try {
socket.send(JSON.stringify({ token }));
} catch {
// ignore
}
}
}; };
socket.onmessage = (event) => { socket.onmessage = (event) => {
@@ -110,6 +117,10 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (data.type === 'ready') { if (data.type === 'ready') {
set({ connected: true }); set({ connected: true });
reconnectDelay = 1000; reconnectDelay = 1000;
const activeChan = useChannelStore.getState().activeChannelId;
if (activeChan) useMessageStore.getState().fetchMessages(activeChan);
const activeConv = useConversationStore.getState().activeConversationId;
if (activeConv) useConversationStore.getState().fetchMessages(activeConv);
return; return;
} }
@@ -124,7 +135,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
return; return;
} }
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage; const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage; const removeMessage = useMessageStore.getState().removeMessage;
const addReaction = useMessageStore.getState().addReaction; const addReaction = useMessageStore.getState().addReaction;
@@ -141,15 +151,17 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (isRecord(payload)) { if (isRecord(payload)) {
if (payload.conversation_id) { if (payload.conversation_id) {
const msg = payload as unknown as ConversationMessage; const msg = payload as unknown as ConversationMessage;
useConversationStore.getState().addMessage(msg); const normalizedMsg = { ...msg, conversation_id: (msg.conversation_id || '').toLowerCase() };
useConversationStore.getState().addMessage(normalizedMsg);
// auto-mark DM as read if this conversation is active // auto-mark DM as read if this conversation is active
const activeConvId = useConversationStore.getState().activeConversationId; const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
if (msg.conversation_id === activeConvId && document.hasFocus()) { if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id); useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
} }
} else { } else {
const msg = payload as unknown as Message; const msg = payload as unknown as Message;
addMessage(msg); const normalizedMsg = { ...msg, channel_id: (msg.channel_id || '').toLowerCase() };
useMessageStore.getState().addMessage(normalizedMsg);
// Desktop notification // Desktop notification
const currentUserId = useAuthStore.getState().user?.id; const currentUserId = useAuthStore.getState().user?.id;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AudioRenderers.tsx","./src/components/BotManager.tsx","./src/components/BotStore.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ConnectionStatus.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DeviceSettingsModal.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ExpandableImage.tsx","./src/components/FeatureRequestsPanel.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/FormatToolbar.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallBanner.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageInput.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/NotificationPrompt.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VersionNotifier.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/featureRequest.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/voicePresence.ts","./src/stores/ws.ts"],"version":"5.9.3"}