diff --git a/DumpsterPixelArt.png b/DumpsterPixelArt.png new file mode 100644 index 0000000..7443a17 Binary files /dev/null and b/DumpsterPixelArt.png differ diff --git a/cmd/server/main.go b/cmd/server/main.go index ce7ee72..895b86e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,9 +22,11 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/notification" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/readstate" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice" @@ -212,6 +214,16 @@ func main() { message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r) }) + // Per-channel notification settings + r.Route("/channels/{channelID}/notifications", func(r chi.Router) { + notification.NewHandler(database.DB, logger).RegisterRoutes(r) + }) + + // Read receipts + rsHandler := readstate.NewHandler(database.DB, logger) + r.Put("/channels/{channelID}/read", rsHandler.MarkRead) + r.Get("/users/me/read-states", rsHandler.GetAll) + // Giphy search if giphyClient != nil { r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/db/db.go b/internal/db/db.go index 57b26e3..f3e6eed 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -215,6 +215,23 @@ CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); +-- Per-channel notification settings +CREATE TABLE IF NOT EXISTS notification_settings ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + level VARCHAR(16) NOT NULL DEFAULT 'all', + PRIMARY KEY (user_id, channel_id) +); + +-- Read receipts (per-user, per-channel) +CREATE TABLE IF NOT EXISTS read_states ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_read_message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, channel_id) +); + -- Direct message conversations CREATE TABLE IF NOT EXISTS conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/internal/message/mentions.go b/internal/message/mentions.go index b43508b..0b246cf 100644 --- a/internal/message/mentions.go +++ b/internal/message/mentions.go @@ -76,10 +76,13 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID } if isEveryone { - // Send to all server members + // Send to all server members except those who muted this channel rows, err := m.db.QueryContext(ctx, - `SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`, - serverID, authorID, + `SELECT m.user_id FROM members m + LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3 + WHERE m.server_id = $1 AND m.user_id != $2 + AND (ns.level IS NULL OR ns.level != 'none')`, + serverID, authorID, channelID, ) if err != nil { m.logger.Error("failed to query server members for @everyone", "error", err) @@ -141,6 +144,16 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID continue } + // Check if user muted this channel + var level string + err = m.db.QueryRowContext(ctx, + `SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`, + userID, channelID, + ).Scan(&level) + if err == nil && level == "none" { + continue + } + go m.push.SendPush(ctx, userID, payload) } } diff --git a/internal/notification/handlers.go b/internal/notification/handlers.go new file mode 100644 index 0000000..6ebad0a --- /dev/null +++ b/internal/notification/handlers.go @@ -0,0 +1,90 @@ +package notification + +import ( + "database/sql" + "encoding/json" + "log/slog" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type Handler struct { + db *sql.DB + logger *slog.Logger +} + +func NewHandler(db *sql.DB, logger *slog.Logger) *Handler { + return &Handler{db: db, logger: logger} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Put("/{channelID}/notifications", h.Set) + r.Get("/me/notifications", h.GetAll) +} + +type setNotificationRequest struct { + Level string `json:"level"` +} + +func (h *Handler) Set(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.UserIDFromContext(r.Context()) + channelID := chi.URLParam(r, "channelID") + + var req setNotificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + + if req.Level != "all" && req.Level != "mentions" && req.Level != "none" { + http.Error(w, `{"error":"level must be all, mentions, or none"}`, http.StatusBadRequest) + return + } + + _, err := h.db.Exec(` + INSERT INTO notification_settings (user_id, channel_id, level) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, channel_id) DO UPDATE SET level = $3 + `, userID, channelID, req.Level) + if err != nil { + h.logger.Error("failed to set notification level", "error", err, "user_id", userID, "channel_id", channelID) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "level": req.Level}) +} + +type ChannelSetting struct { + ChannelID string `json:"channel_id"` + Level string `json:"level"` +} + +func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.UserIDFromContext(r.Context()) + + rows, err := h.db.Query(` + SELECT channel_id, level FROM notification_settings WHERE user_id = $1 + `, userID) + if err != nil { + h.logger.Error("failed to query notification settings", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + settings := make(map[string]string) + for rows.Next() { + var channelID, level string + if err := rows.Scan(&channelID, &level); err != nil { + continue + } + settings[channelID] = level + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} diff --git a/internal/push/handlers.go b/internal/push/handlers.go index afb01d0..16a5835 100644 --- a/internal/push/handlers.go +++ b/internal/push/handlers.go @@ -212,8 +212,10 @@ func (h *Handler) SendChannelNotification(ctx context.Context, channelID, author SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth FROM push_subscriptions ps JOIN members m ON m.user_id = ps.user_id + LEFT JOIN notification_settings ns ON ns.user_id = ps.user_id AND ns.channel_id = $3 WHERE m.server_id = $1 AND ps.user_id != $2 - `, serverID, authorID) + AND (ns.level IS NULL OR ns.level = 'all') + `, serverID, authorID, channelID) if err != nil { h.logger.Error("failed to query push subscriptions", "error", err) return diff --git a/internal/readstate/handlers.go b/internal/readstate/handlers.go new file mode 100644 index 0000000..fdc8dda --- /dev/null +++ b/internal/readstate/handlers.go @@ -0,0 +1,94 @@ +package readstate + +import ( + "database/sql" + "encoding/json" + "log/slog" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type Handler struct { + db *sql.DB + logger *slog.Logger +} + +func NewHandler(db *sql.DB, logger *slog.Logger) *Handler { + return &Handler{db: db, logger: logger} +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Put("/{channelID}/read", h.MarkRead) + r.Get("/users/me/read-states", h.GetAll) +} + +type markReadRequest struct { + LastReadMessageID string `json:"last_read_message_id"` +} + +func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.UserIDFromContext(r.Context()) + channelID := chi.URLParam(r, "channelID") + + var req markReadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + if req.LastReadMessageID == "" { + http.Error(w, `{"error":"last_read_message_id is required"}`, http.StatusBadRequest) + return + } + + _, err := h.db.Exec(` + INSERT INTO read_states (user_id, channel_id, last_read_message_id, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, channel_id) DO UPDATE SET + last_read_message_id = $3, + updated_at = NOW() + `, userID, channelID, req.LastReadMessageID) + if err != nil { + h.logger.Error("failed to mark read", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// ChannelReadState is returned per channel +type ChannelReadState struct { + ChannelID string `json:"channel_id"` + LastReadMessageID string `json:"last_read_message_id"` +} + +func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { + userID, _ := middleware.UserIDFromContext(r.Context()) + + rows, err := h.db.Query(` + SELECT rs.channel_id, rs.last_read_message_id + FROM read_states rs + WHERE rs.user_id = $1 + `, userID) + if err != nil { + h.logger.Error("failed to query read states", "error", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + states := make(map[string]string) + for rows.Next() { + var channelID, lastReadMessageID string + if err := rows.Scan(&channelID, &lastReadMessageID); err != nil { + continue + } + states[channelID] = lastReadMessageID + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(states) +} diff --git a/web/index.html b/web/index.html index 7f7423d..ca2ec1e 100644 --- a/web/index.html +++ b/web/index.html @@ -2,14 +2,14 @@ - + + - Dumpster diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 0000000..61a5742 Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000..7443a17 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index ab87c6f..f9fd94f 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -5,6 +5,18 @@ import { VoiceChannel } from './VoiceChannel.tsx'; import { CreateChannelModal } from './CreateChannelModal.tsx'; import { InviteModal } from './InviteModal.tsx'; import { ChannelSettingsModal } from './ChannelSettingsModal.tsx'; +import { useNotificationSettingsStore } from '../stores/notificationSettings.ts'; +import { useReadStatesStore } from '../stores/readStates.ts'; + +type NotifLevel = 'all' | 'mentions' | 'none'; + +const NOTIF_LABELS: Record = { + all: '🔔 All', + mentions: '🔔 @', + none: '🔕 Muted', +}; + +const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none']; export function ChannelList() { const activeServerId = useServerStore((state) => state.activeServerId); @@ -17,12 +29,24 @@ export function ChannelList() { const [showInvite, setShowInvite] = useState(false); const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null); + const notifSettings = useNotificationSettingsStore((state) => state.settings); + const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings); + const setNotifLevel = useNotificationSettingsStore((state) => state.setLevel); + + const readStates = useReadStatesStore((state) => state.states); + const fetchReadStates = useReadStatesStore((state) => state.fetchStates); + useEffect(() => { if (activeServerId) { fetchChannels(activeServerId); } }, [activeServerId, fetchChannels]); + useEffect(() => { + fetchNotifSettings(); + fetchReadStates(); + }, [fetchNotifSettings, fetchReadStates]); + const channels = useMemo(() => { return activeServerId ? channelsByServer[activeServerId] || [] : []; }, [activeServerId, channelsByServer]); @@ -46,6 +70,28 @@ export function ChannelList() { return Array.from(map.entries()); }, [channels]); + const getLevel = (channelId: string): NotifLevel => { + return notifSettings[channelId] || 'all'; + }; + + const handleNotifClick = (e: React.MouseEvent, channelId: string) => { + e.stopPropagation(); + const current = getLevel(channelId); + const idx = NOTIF_CYCLE.indexOf(current); + const next = NOTIF_CYCLE[(idx + 1) % NOTIF_CYCLE.length]; + setNotifLevel(channelId, next); + }; + + const notifIcon = (channelId: string) => { + const level = getLevel(channelId); + if (level === 'none') return '🔕'; + return level === 'mentions' ? '🔔@' : '🔔'; + }; + + const hasUnread = (channelId: string): boolean => { + return !(channelId in readStates); + }; + return (
@@ -92,16 +138,30 @@ export function ChannelList() { channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s' }`} > - {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'} + + {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'} + {channel.name} - { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }} - className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100" - title="Channel settings" - role="button" - tabIndex={0} - > - [⚙] + {hasUnread(channel.id) && ( + + )} + + + { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }} + className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100" + title="Channel settings" + role="button" + tabIndex={0} + > + [⚙] +
diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 50855ed..4174bc1 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -6,6 +6,7 @@ import { useMemberStore } from "../stores/member.ts"; import { useThreadStore } from "../stores/thread.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { MentionDropdown } from "./MentionDropdown"; +import { useReadStatesStore } from "../stores/readStates.ts"; import { MessageSearch } from "./MessageSearch"; import { ThreadListPanel } from "./ThreadListPanel.tsx"; import { ThreadPanel } from "./ThreadPanel.tsx"; @@ -140,6 +141,7 @@ export function ChatArea() { const activeChannel = channels.find((c) => c.id === activeChannelId); const members = activeServerId ? membersByServer[activeServerId] || [] : []; const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]); + const markRead = useReadStatesStore((s) => s.markRead); useEffect(() => { if (activeChannelId) { @@ -148,6 +150,14 @@ export function ChatArea() { } }, [activeChannelId, fetchMessages]); + useEffect(() => { + if (!activeChannelId || messages.length === 0 || isLoading) return; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.id) { + markRead(activeChannelId, lastMsg.id); + } + }, [activeChannelId, messages.length, isLoading]); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); diff --git a/web/src/stores/notificationSettings.ts b/web/src/stores/notificationSettings.ts new file mode 100644 index 0000000..a7aa397 --- /dev/null +++ b/web/src/stores/notificationSettings.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { api } from '../lib/api'; + +type NotificationLevel = 'all' | 'mentions' | 'none'; + +interface NotificationSettingsState { + settings: Record; + initialized: boolean; + loaded: boolean; + fetchSettings: () => Promise; + setLevel: (channelId: string, level: NotificationLevel) => Promise; +} + +export const useNotificationSettingsStore = create()((set) => ({ + settings: {}, + initialized: false, + loaded: false, + + fetchSettings: async () => { + try { + const settings = await api.get>('/channels/me/notifications'); + set({ settings, loaded: true, initialized: true }); + } catch { + set({ loaded: true, initialized: true }); + } + }, + + setLevel: async (channelId: string, level: NotificationLevel) => { + await api.put(`/channels/${channelId}/notifications`, { level }); + set((state) => ({ + settings: { ...state.settings, [channelId]: level }, + })); + }, +})); diff --git a/web/src/stores/readStates.ts b/web/src/stores/readStates.ts new file mode 100644 index 0000000..6d22004 --- /dev/null +++ b/web/src/stores/readStates.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { api } from '../lib/api'; + +interface ReadStatesState { + states: Record; // channel_id -> last_read_message_id + loaded: boolean; + fetchStates: () => Promise; + markRead: (channelId: string, messageId: string) => Promise; + hasUnread: (channelId: string) => boolean; // true if no read state (never viewed) +} + +export const useReadStatesStore = create()((set, get) => ({ + states: {}, + loaded: false, + + fetchStates: async () => { + try { + const states = await api.get>('/users/me/read-states'); + set({ states, loaded: true }); + } catch { + set({ loaded: true }); + } + }, + + markRead: async (channelId: string, messageId: string) => { + set((state) => ({ + states: { ...state.states, [channelId]: messageId }, + })); + try { + await api.put(`/channels/${channelId}/read`, { last_read_message_id: messageId }); + } catch { + // optimistic update, ignore failure + } + }, + + hasUnread: (channelId: string): boolean => { + const state = get().states; + // No read state means the user has never viewed this channel + return !(channelId in state); + }, +})); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index d67db0c..6e25078 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.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/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.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/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.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/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.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/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.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/ws.ts"],"version":"5.9.3"} \ No newline at end of file