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