Compare commits

...

24 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
24 changed files with 583 additions and 323 deletions
+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)
}) })
} }
+6
View File
@@ -17,6 +17,12 @@ fi
git pull git pull
make build make build
# 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 # Health check after restart — roll back on failure
systemctl restart dumpster systemctl restart dumpster
echo "Waiting for app to become healthy..." echo "Waiting for app to become healthy..."
+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}
} }
+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:
+1 -1
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",
+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={() => {
+25 -25
View File
@@ -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"}