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-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-tui:
+14 -3
View File
@@ -47,9 +47,8 @@ import (
// @host localhost:8080
// @BasePath /api/v1
// @schemes http https
// @securityDefinitions.apikey SessionAuth
// @in cookie
// @name dumpster_session
var GitSHA = "dev"
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
@@ -153,6 +152,14 @@ func main() {
// API routes
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
r.Route("/auth", func(r chi.Router) {
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)
path := staticDir + r.URL.Path
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")
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)
})
}
+6
View File
@@ -17,6 +17,12 @@ fi
git pull
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
systemctl restart dumpster
echo "Waiting for app to become healthy..."
+4 -4
View File
@@ -4,14 +4,14 @@
}
:80 {
# API routes
handle_path /api/* {
reverse_proxy app:8080
# API routes — use handle (not handle_path) to preserve the /api/v1 prefix
handle /api/* {
reverse_proxy 172.18.0.1:8080
}
# WebSocket gateway
handle /ws {
reverse_proxy app:8080 {
reverse_proxy 172.18.0.1:8080 {
header_up Connection {>Connection}
header_up Upgrade {>Upgrade}
}
+45 -33
View File
@@ -263,40 +263,52 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
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
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()`,
token,
).Scan(&userID, &username)
if err != nil {
logger.Warn("ws auth: invalid session", "error", err)
conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid session"}`))
// 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()`,
hashToken(token),
).Scan(&userID, &username)
if err == nil && userID != "" {
break
}
}
// 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()
return
}
+25 -8
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"log/slog"
"strings"
"sync"
"time"
)
@@ -285,15 +286,30 @@ func (h *Hub) BroadcastToServer(serverID string, event Event) {
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()
defer h.mu.RUnlock()
for client := range h.clients {
servers, ok := h.userServers[client.UserID]
if !ok {
continue
}
if _, member := servers[serverID]; member {
cID := strings.ToLower(strings.TrimSpace(client.UserID))
if members[cID] {
select {
case client.send <- data:
default:
@@ -322,18 +338,19 @@ func (h *Hub) BroadcastToConversation(convID string, event Event) {
}
defer rows.Close()
members := make(map[string]struct{})
members := make(map[string]bool)
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err == nil {
members[uid] = struct{}{}
members[strings.ToLower(strings.TrimSpace(uid))] = true
}
}
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
if _, ok := members[client.UserID]; ok {
cID := strings.ToLower(strings.TrimSpace(client.UserID))
if members[cID] {
select {
case client.send <- data:
default:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "dumpsterChat",
"version": "0.2.9",
"version": "0.2.10",
"identifier": "coffee.dustin.dumpster",
"build": {
"frontendDist": "../dist",
+13 -1
View File
@@ -117,9 +117,21 @@ export function ChannelList() {
}, [fetchNotifSettings, fetchReadStates]);
const channels = useMemo(() => {
return activeServerId ? channelsByServer[activeServerId] || [] : [];
return activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
}, [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(() => {
if (!activeServerId) return 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 { api } from "../lib/api.ts";
import { useChannelStore } from "../stores/channel.ts";
@@ -284,14 +284,14 @@ const MessageItem = memo(({
MessageItem.displayName = "MessageItem";
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 activeServerId = useServerStore((s) => s.activeServerId);
const messages = useMessageStore((s) =>
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
);
const messages = useMessageStore((s) => (activeChannelId ? s.messagesByChannel[activeChannelId] || [] : []));
const isLoading = useMessageStore((s) => s.isLoading);
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
const fetchMessages = useMessageStore((s) => s.fetchMessages);
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
const sendMessage = useMessageStore((s) => s.sendMessage);
@@ -332,8 +332,8 @@ export function ChatArea() {
);
const canBulkDelete = true;
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const activeChannel = channels.find((c) => c.id === activeChannelId);
const channels = activeServerId ? channelsByServer[activeServerId.toLowerCase()] || [] : [];
const activeChannel = channels.find((c) => c.id.toLowerCase() === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
// Humans only for mentions / nickname lookup (bots live in member list separately).
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
@@ -447,19 +447,21 @@ export function ChatArea() {
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el || !activeChannelId || isLoadingOlder) return;
if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight;
fetchOlderMessages(activeChannelId).then(() => {
// ponytail: maintain scroll position after prepending older messages
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight - prevHeight;
});
});
}
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
}, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
@@ -846,9 +848,8 @@ export function ChatArea() {
const lastReadId = activeChannelId ? readStates[activeChannelId] : undefined;
const showDivider = lastReadId && message.id === lastReadId && i < messages.length - 1;
return (
<>
<Fragment key={message.id}>
<MessageItem
key={message.id}
message={message}
memberUsernames={memberUsernames}
selectMode={selectMode}
@@ -873,7 +874,7 @@ export function ChatArea() {
<span className="flex-1 border-t border-gb-red"></span>
</div>
)}
</>
</Fragment>
);
})}
<div ref={bottomRef} />
+10 -7
View File
@@ -16,9 +16,10 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
export function ConversationList() {
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 createConversation = useConversationStore((s) => s.createConversation);
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const currentUser = useAuthStore((s) => s.user);
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
@@ -31,8 +32,9 @@ export function ConversationList() {
fetchConversations();
}, [fetchConversations]);
const openConversation = (convId: string) => {
// mark as read when opening
const openConversation = (rawId: string) => {
const convId = rawId.toLowerCase();
setActiveConversation(convId);
const msgs = messagesByConv[convId] || [];
if (msgs.length > 0) {
markConvRead(convId, msgs[msgs.length - 1].id);
@@ -50,7 +52,7 @@ export function ConversationList() {
try {
const conv = await createConversation([]);
if (conv) {
navigate(`/dm/${conv.id}`);
openConversation(conv.id);
}
} catch (err) {
console.error("Failed to create notes:", err);
@@ -59,7 +61,7 @@ export function ConversationList() {
};
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;
};
@@ -89,19 +91,20 @@ export function ConversationList() {
<p className="text-gb-fg-f">[no conversations]</p>
)}
{conversations.map((conv) => {
const convId = conv.id.toLowerCase();
const self = isSelfDM(conv, currentUser?.id || "");
const name = self
? "Notes"
: conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || "");
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id));
const unread = hasConvUnread(convId, getLatestMessageId(convId));
return (
<button
key={conv.id}
onClick={() => openConversation(conv.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId
convId === activeId
? "terminal-active"
: "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 { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
@@ -152,7 +152,6 @@ export function DMChat() {
const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages);
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
@@ -173,8 +172,18 @@ export function DMChat() {
const markConvRead = useReadStatesStore((s) => s.markConvRead);
const convStates = useReadStatesStore((s) => s.convStates);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id);
const rawId = conversationId || activeId;
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) => {
if (!id) return;
@@ -189,7 +198,7 @@ export function DMChat() {
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el || !id || isLoadingOlder) return;
if (!el || !id || isLoadingOlder || !hasMore) return;
if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight;
fetchOlderMessages(id).then(() => {
@@ -198,8 +207,7 @@ export function DMChat() {
});
});
}
}, [id, isLoadingOlder, fetchOlderMessages]);
const messages = id ? messagesByConv[id] || [] : [];
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
useEffect(() => {
fetchConversations();
@@ -213,9 +221,7 @@ export function DMChat() {
}
}, [id, setActive, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => {
if (!id || messages.length === 0 || isLoading) return;
@@ -329,9 +335,8 @@ export function DMChat() {
const lastReadId = id ? convStates[id] : undefined;
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
return (
<>
<Fragment key={msg.id}>
<DMMessageItem
key={msg.id}
msg={msg}
onAddReaction={handleAddReaction}
activeReactionMessageId={activeReactionMessageId}
@@ -348,7 +353,7 @@ export function DMChat() {
<span className="flex-1 border-t border-gb-red"></span>
</div>
)}
</>
</Fragment>
);
})}
<div ref={bottomRef} />
+4 -5
View File
@@ -15,6 +15,7 @@ import { MemberList } from './MemberList.tsx';
import { VoicePanel } from './VoicePanel.tsx';
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
import { ThemeToggle } from './ThemeToggle.tsx';
import { VersionNotifier } from './VersionNotifier.tsx';
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
function statusColor(status: UserStatus): string {
@@ -35,8 +36,6 @@ export function Layout() {
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const updateProfile = useAuthStore((state) => state.updateProfile);
const wsConnect = useWebSocketStore((s) => s.connect);
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
const navigate = useNavigate();
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
@@ -63,9 +62,8 @@ export function Layout() {
}, [currentVoiceRoom]);
useEffect(() => {
wsConnect();
return () => { wsDisconnect(); };
}, [wsConnect, wsDisconnect]);
useWebSocketStore.getState().connect();
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
@@ -102,6 +100,7 @@ export function Layout() {
return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col">
<VersionNotifier />
{/* Outer terminal frame with safe area */}
<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)' }}>
+4 -4
View File
@@ -117,7 +117,7 @@ export function LoginForm() {
</button>
</form>
{!isRegister && (
<div className="mt-3 text-center">
<div className="mt-4 text-center">
<Link
to="/forgot-password"
className="text-gb-yellow hover:text-gb-orange text-xs"
@@ -127,7 +127,7 @@ export function LoginForm() {
</div>
)}
{!isRegister && (
<div className="mt-3">
<div className="mt-4">
<button
type="button"
onClick={async () => {
@@ -157,13 +157,13 @@ export function LoginForm() {
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]
</button>
</div>
)}
<div className="mt-4 text-center">
<div className="mt-5 text-center">
<button
type="button"
onClick={() => {
+25 -25
View File
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
<button type="button" disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
title="Upload file"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
</button>
<span className="text-gb-bg-t mx-1"></span>
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
{/* block formatting */}
<button type="button" disabled={disabled}
onClick={() => execBlock("ul")} title="Unordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("ol")} title="Ordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("blockquote")} title="Blockquote"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={execLink} title="Insert link"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faLink} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("h2")} title="Heading"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
</button>
<span className="text-gb-bg-t mx-1"></span>
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
{/* inline formatting */}
<button type="button" disabled={disabled}
onClick={() => execInline("**", "**")} title="Bold"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faBold} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("*", "*")} title="Italic"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("~~", "~~")} title="Strikethrough"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("`", "`")} title="Code"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faCode} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("||", "||")} title="Spoiler"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
<FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* emoji / kaomoji / gif */}
<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" />
</button>
<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" />
</button>
<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" />
</button>
+10 -1
View File
@@ -1,4 +1,5 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useConversationStore } from "../stores/conversation.ts";
import { useMemberStore } from "../stores/member.ts";
import { useServerStore } from "../stores/server.ts";
@@ -11,6 +12,7 @@ interface NewConversationModalProps {
export function NewConversationModal({ onClose }: NewConversationModalProps) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const navigate = useNavigate();
const createConversation = useConversationStore((s) => s.createConversation);
const activeServerId = useServerStore((s) => s.activeServerId);
const membersByServer = useMemberStore((s) => s.membersByServer);
@@ -48,7 +50,14 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
const handleCreate = async () => {
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();
};
+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
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
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-left', '0px');
document.documentElement.style.setProperty('--safe-right', '0px');
+4 -2
View File
@@ -84,7 +84,7 @@ function autoSubscribePush() {
});
}
export const useAuthStore = create<AuthState>((set) => ({
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
@@ -146,7 +146,9 @@ export const useAuthStore = create<AuthState>((set) => ({
},
fetchMe: async () => {
set({ isLoading: true, error: null });
if (!get().user) {
set({ isLoading: true, error: null });
}
try {
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
set({ user, isAuthenticated: true, isLoading: false });
+41 -18
View File
@@ -33,11 +33,18 @@ export const useChannelStore = create<ChannelState>((set) => ({
error: null,
fetchChannels: async (serverId) => {
const srvId = serverId.toLowerCase();
set({ isLoading: true, error: null });
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) => ({
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
channelsByServer: { ...state.channelsByServer, [srvId]: normalized },
isLoading: false,
}));
} catch (error) {
@@ -49,50 +56,66 @@ export const useChannelStore = create<ChannelState>((set) => ({
},
setActiveChannel: (id) => {
set({ activeChannelId: id });
if (id) {
const chId = id ? id.toLowerCase() : null;
set({ activeChannelId: chId });
if (chId) {
// ponytail: persist last active channel for session restore
const state = useChannelStore.getState();
for (const [serverId, channels] of Object.entries(state.channelsByServer)) {
if (channels.some((c) => c.id === id)) {
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: id }));
if (channels.some((c) => c.id.toLowerCase() === chId)) {
localStorage.setItem('dumpster:lastChannel', JSON.stringify({ serverId, channelId: chId }));
break;
}
}
}
},
addChannel: (channel) =>
addChannel: (channel) => {
const norm = {
...channel,
id: channel.id.toLowerCase(),
server_id: channel.server_id.toLowerCase(),
};
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 {
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) => {
const list = state.channelsByServer[channel.server_id] || [];
const list = state.channelsByServer[norm.server_id] || [];
return {
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) => {
const next: Record<string, Channel[]> = {};
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 {
channelsByServer: next,
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId,
activeChannelId: state.activeChannelId === chId ? null : state.activeChannelId,
};
}),
});
},
}));
+83 -49
View File
@@ -2,6 +2,13 @@ import { create } from "zustand";
import { api } from "../lib/api.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 {
id: string;
username: string;
@@ -72,121 +79,147 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
createConversation: async (userIds) => {
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
const convId = conversation.id.toLowerCase();
const normalizedConv = { ...conversation, id: convId };
set((state) => ({
conversations: [conversation, ...state.conversations],
activeConversationId: conversation.id,
conversations: [normalizedConv, ...state.conversations],
activeConversationId: convId,
}));
return conversation;
return normalizedConv;
},
setActiveConversation: (id) => set({ activeConversationId: id }),
setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
fetchMessages: async (conversationId, before) => {
const convId = conversationId.toLowerCase();
set({ isLoading: true });
try {
const params = before ? "?before=" + encodeURIComponent(before) : "";
const messages = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages${params}`,
`/conversations/${convId}/messages${params}`,
);
const list = Array.isArray(messages) ? messages : [];
set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: list,
},
hasMoreByConversation: {
...state.hasMoreByConversation,
[conversationId]: list.length >= 50,
},
isLoading: false,
}));
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: {
...state.messagesByConversation,
[convId]: merged,
},
hasMoreByConversation: {
...state.hasMoreByConversation,
[convId]: list.length >= 50,
},
isLoading: false,
};
});
} catch (error) {
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
}
},
fetchOlderMessages: async (conversationId) => {
const convId = conversationId.toLowerCase();
const state = get();
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
const existing = state.messagesByConversation[conversationId] || [];
if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
const existing = state.messagesByConversation[convId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
const older = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
`/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
);
const list = Array.isArray(older) ? older : [];
set((state) => ({
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: [...list, ...existing],
},
hasMoreByConversation: {
...state.hasMoreByConversation,
[conversationId]: list.length >= 50,
},
isLoadingOlder: false,
}));
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: {
...state.messagesByConversation,
[convId]: merged,
},
hasMoreByConversation: {
...state.hasMoreByConversation,
[convId]: list.length >= 50,
},
isLoadingOlder: false,
};
});
} catch {
set({ isLoadingOlder: false });
}
},
sendMessage: async (conversationId, content) => {
// Add locally so the message appears immediately even if WS lags.
// addMessage dedupes, so a late WS event won't double it.
const convId = conversationId.toLowerCase();
const message = await api.post<ConversationMessage>(
`/conversations/${conversationId}/messages`,
`/conversations/${convId}/messages`,
{ content },
);
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
get().addMessage(message);
return message;
const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
get().addMessage(normalizedMessage);
return normalizedMessage;
},
addMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => {
const existing = state.messagesByConversation[message.conversation_id] || [];
if (existing.some((m) => m.id === message.id)) {
const existing = state.messagesByConversation[convId] || [];
if (existing.some((m) => m.id === normalizedMessage.id)) {
return state;
}
return {
messagesByConversation: {
...state.messagesByConversation,
[message.conversation_id]: [...existing, message]
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
[convId]: [...existing, normalizedMessage]
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
},
};
});
},
updateMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => {
const convMsgs = state.messagesByConversation[message.conversation_id] || [];
const convMsgs = state.messagesByConversation[convId] || [];
return {
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) => {
const convId = conversationId.toLowerCase();
set((state) => {
const convMsgs = state.messagesByConversation[conversationId] || [];
const convMsgs = state.messagesByConversation[convId] || [];
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: convMsgs.filter((m) => m.id !== messageId),
[convId]: convMsgs.filter((m) => m.id !== messageId),
},
};
});
},
addReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => {
const messages = state.messagesByConversation[conversationId];
const messages = state.messagesByConversation[convId];
if (!messages) return state;
const newMessages = messages.map((m) => {
@@ -210,15 +243,16 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
[convId]: newMessages,
},
};
});
},
removeReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => {
const messages = state.messagesByConversation[conversationId];
const messages = state.messagesByConversation[convId];
if (!messages) return state;
const newMessages = messages.map((m) => {
@@ -241,7 +275,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
[convId]: newMessages,
},
};
});
+166 -114
View File
@@ -1,6 +1,13 @@
import { create } from "zustand";
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 {
id?: string;
url: string;
@@ -59,7 +66,7 @@ export interface MessageState {
messagesByChannel: Record<string, Message[]>;
pinnedMessagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
selectedMessageIds: Record<string, Set<string>>;
isLoading: boolean;
isLoadingOlder: boolean;
hasMoreByChannel: Record<string, boolean>;
@@ -95,24 +102,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
error: null,
fetchMessages: async (channelId, before) => {
const chId = channelId.toLowerCase();
set({ isLoading: true, error: null });
try {
const params = before ? `?before=${encodeURIComponent(before)}` : "";
const messages = await api.get<Message[]>(
`/channels/${channelId}/messages${params}`,
`/channels/${chId}/messages${params}`,
);
const list = Array.isArray(messages) ? messages : [];
set((state) => ({
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list,
},
hasMoreByChannel: {
...state.hasMoreByChannel,
[channelId]: list.length >= 50,
},
isLoading: false,
}));
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: {
...state.messagesByChannel,
[chId]: merged,
},
hasMoreByChannel: {
...state.hasMoreByChannel,
[chId]: list.length >= 50,
},
isLoading: false,
};
});
} catch (error) {
set({
isLoading: false,
@@ -125,116 +142,136 @@ export const useMessageStore = create<MessageState>((set, get) => ({
},
fetchOlderMessages: async (channelId) => {
const chId = channelId.toLowerCase();
const state = get();
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
const existing = state.messagesByChannel[channelId] || [];
if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
const existing = state.messagesByChannel[chId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
const older = await api.get<Message[]>(
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`,
`/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
);
const list = Array.isArray(older) ? older : [];
set((state) => ({
messagesByChannel: {
...state.messagesByChannel,
[channelId]: [...list, ...existing],
},
hasMoreByChannel: {
...state.hasMoreByChannel,
[channelId]: list.length >= 50,
},
isLoadingOlder: false,
}));
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: {
...state.messagesByChannel,
[chId]: merged,
},
hasMoreByChannel: {
...state.hasMoreByChannel,
[chId]: list.length >= 50,
},
isLoadingOlder: false,
};
});
} catch {
set({ isLoadingOlder: false });
}
},
searchMessages: async (channelId, query) => {
const chId = channelId.toLowerCase();
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) => ({
searchResultsByChannel: {
...state.searchResultsByChannel,
[channelId]: Array.isArray(results) ? results : [],
},
}));
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,
[chId]: 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) => {
const list = state.messagesByChannel[message.channel_id] || [];
if (list.some((m) => m.id === message.id)) {
const list = state.messagesByChannel[chId] || [];
if (list.some((m) => m.id === normalizedMessage.id)) {
return state;
}
return {
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) => {
const list = state.messagesByChannel[message.channel_id] || [];
const list = state.messagesByChannel[chId] || [];
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];
if (message.pinned) {
if (!pinnedList.some((m) => m.id === message.id)) {
updatedPinned = [message, ...pinnedList].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
if (normalizedMessage.pinned) {
if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
updatedPinned = [normalizedMessage, ...pinnedList].sort(
(a, b) => parseDate(b.created_at) - parseDate(a.created_at)
);
} else {
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
}
} else {
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
}
if (updatedPinned.length > 5) {
updatedPinned = updatedPinned.slice(0, 5);
@@ -243,34 +280,38 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channel_id]: updatedList,
[chId]: updatedList,
},
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[message.channel_id]: updatedPinned,
[chId]: updatedPinned,
},
};
}),
});
},
removeMessage: (channelId, messageId) =>
removeMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
const list = state.messagesByChannel[chId] || [];
const pinnedList = state.pinnedMessagesByChannel[chId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId),
[chId]: list.filter((m) => m.id !== messageId),
},
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) => {
const list = state.messagesByChannel[channelId] || [];
const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
const reactions = m.reactions ? [...m.reactions] : [];
@@ -288,14 +329,16 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
[chId]: updatedList,
},
};
}),
});
},
removeReaction: (channelId, messageId, emoji, userId) =>
removeReaction: (channelId, messageId, emoji, userId) => {
const chId = channelId.toLowerCase();
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
if (!m.reactions) return m;
@@ -311,25 +354,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
[chId]: updatedList,
},
};
}),
});
},
bulkDeleteMessages: async (channelId, messageIds) => {
const chId = channelId.toLowerCase();
const res = await api.post<{ deleted: number }>(
`/channels/${channelId}/messages/bulk-delete`,
`/channels/${chId}/messages/bulk-delete`,
{ messages: messageIds },
);
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const list = state.messagesByChannel[chId] || [];
const ids = new Set(messageIds);
const selected = { ...state.selectedMessageIds };
delete selected[channelId];
delete selected[chId];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => !ids.has(m.id)),
[chId]: list.filter((m) => !ids.has(m.id)),
},
selectedMessageIds: selected,
};
@@ -337,9 +382,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return res;
},
toggleSelectedMessage: (channelId, messageId) =>
toggleSelectedMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => {
const current = state.selectedMessageIds[channelId] || new Set<string>();
const current = state.selectedMessageIds[chId] || new Set<string>();
const next = new Set(current);
if (next.has(messageId)) {
next.delete(messageId);
@@ -349,21 +395,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return {
selectedMessageIds: {
...state.selectedMessageIds,
[channelId]: next,
[chId]: next,
},
};
}),
});
},
clearSelectedMessages: (channelId) =>
clearSelectedMessages: (channelId) => {
const chId = channelId.toLowerCase();
set((state) => {
const next = { ...state.selectedMessageIds };
delete next[channelId];
delete next[chId];
return { selectedMessageIds: next };
}),
});
},
createPoll: async (channelId, question, options) => {
const chId = channelId.toLowerCase();
const resp = await api.post<Poll>("/polls", {
channel_id: channelId,
channel_id: chId,
question,
options,
});
@@ -374,9 +424,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
},
updatePoll: (channelId, poll) =>
updatePoll: (channelId, poll) => {
const chId = channelId.toLowerCase();
set((state) => {
const messages = state.messagesByChannel[channelId];
const messages = state.messagesByChannel[chId];
if (!messages) return state;
const updated = messages.map((m) =>
m.poll?.id === poll.id ? { ...m, poll } : m,
@@ -384,8 +435,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return {
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) => {
const chId = channelId.toLowerCase();
set((state) => ({
states: { ...state.states, [channelId]: messageId },
states: { ...state.states, [chId]: messageId },
}));
try {
await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId });
await api.put(`/channels/${chId}/read`, { last_read_message_id: messageId });
} catch {
// optimistic update, ignore failure
}
},
markConvRead: (conversationId: string, messageId: string) => {
const convId = conversationId.toLowerCase();
set((state) => ({
convStates: { ...state.convStates, [conversationId]: messageId },
convStates: { ...state.convStates, [convId]: messageId },
}));
},
hasUnread: (channelId: string, latestMessageId?: string): boolean => {
const chId = channelId.toLowerCase();
const state = get().states;
const lastRead = state[channelId];
const lastRead = state[chId];
// never viewed: unread if there are messages
if (!lastRead) return !!latestMessageId;
// viewed but newer messages exist
@@ -54,8 +57,9 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
},
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
const convId = conversationId.toLowerCase();
const state = get().convStates;
const lastRead = state[conversationId];
const lastRead = state[convId];
if (!lastRead) return !!latestMessageId;
if (latestMessageId && lastRead !== latestMessageId) return true;
return false;
+26 -14
View File
@@ -61,11 +61,15 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
? payload.id
: null;
if (!messageId) return null;
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId };
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_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.toLowerCase(), message_id: messageId };
return null;
}
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null,
connected: false,
@@ -78,10 +82,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
const socket = new WebSocket(`${getWsHost()}/ws`);
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
const scheduleReconnect = () => {
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
@@ -95,8 +95,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
};
socket.onopen = () => {
// Cookie-based auth: browser sends session cookie automatically.
// No need to send a token frame.
reconnectDelay = 1000; // ponytail: reset backoff on successful connect
const token = localStorage.getItem('dumpster_session_token');
if (token) {
try {
socket.send(JSON.stringify({ token }));
} catch {
// ignore
}
}
};
socket.onmessage = (event) => {
@@ -110,6 +117,10 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (data.type === 'ready') {
set({ connected: true });
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;
}
@@ -124,7 +135,6 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
return;
}
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage;
const addReaction = useMessageStore.getState().addReaction;
@@ -141,15 +151,17 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (isRecord(payload)) {
if (payload.conversation_id) {
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
const activeConvId = useConversationStore.getState().activeConversationId;
if (msg.conversation_id === activeConvId && document.hasFocus()) {
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id);
const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
}
} else {
const msg = payload as unknown as Message;
addMessage(msg);
const normalizedMsg = { ...msg, channel_id: (msg.channel_id || '').toLowerCase() };
useMessageStore.getState().addMessage(normalizedMsg);
// Desktop notification
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"}