feat: real-time sync, notification & social system overhaul
- Add WS events: dm_channel_created, dm_channel_closed, friend_removed, channel_created/updated/deleted, server_updated - Fix first-ever DM: broadcast dm_channel_created to recipient - Add DELETE /api/dm/:id for closing DMs with re-open support - Wire Close DM button in sidebar - Fix dm_message_created for unknown channels (safety net) - Broadcast friend_removed on friend deletion - Sound system: track realtimeMessageEvents separately from API loads, play notification for messages in all channels, not just current - Optimistic updates: send/edit/delete messages appear instantly with rollback on failure, temp message deduplication on WS echo - Channel CRUD broadcasts to all server members - Server update broadcast on PATCH - Server join registers user in connectionManager immediately - Reconnect: only reload current channel, preserve other channel caches - Activity panel, right panel, member list toggle components
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel } from './serverStore';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
export const useChatStore = create((set, get) => ({
|
||||
messages: new Map(),
|
||||
currentChannelId: null,
|
||||
@@ -12,6 +13,7 @@ export const useChatStore = create((set, get) => ({
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
@@ -67,34 +69,98 @@ export const useChatStore = create((set, get) => ({
|
||||
sendMessage: async (channelId, content, attachmentIds) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
}
|
||||
else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
|
||||
// Generate optimistic message
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (currentUser) {
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
channelId: isDm ? '' : channelId,
|
||||
userId: currentUser.id,
|
||||
content,
|
||||
replyToId: replyToId ?? null,
|
||||
editedAt: null,
|
||||
createdAt: Date.now(),
|
||||
user: currentUser,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
};
|
||||
if (isDm) {
|
||||
optimisticMessage.dmChannelId = channelId;
|
||||
}
|
||||
// Add optimistic message immediately
|
||||
get().addMessage(channelId, optimisticMessage);
|
||||
|
||||
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
|
||||
if (isDm) {
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === channelId
|
||||
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
|
||||
: dm
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
}
|
||||
}
|
||||
|
||||
set({ replyTo: null });
|
||||
// Message will arrive via WebSocket
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: remove the optimistic message on failure
|
||||
get().removeMessage(tempId, channelId);
|
||||
}
|
||||
},
|
||||
editMessage: async (messageId, content, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
// Optimistic: update content locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const originalMessage = messages?.find(m => m.id === messageId);
|
||||
if (originalMessage) {
|
||||
get().updateMessage({ ...originalMessage, content, editedAt: Date.now() });
|
||||
}
|
||||
else {
|
||||
await api.messages.update(messageId, { content });
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: restore the original message on failure
|
||||
if (originalMessage) {
|
||||
get().updateMessage(originalMessage);
|
||||
}
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
deleteMessage: async (messageId, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
// Optimistic: remove locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const savedMessage = messages?.find(m => m.id === messageId);
|
||||
get().removeMessage(messageId, channelId);
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
} catch {
|
||||
// Rollback: re-add the message on failure
|
||||
if (savedMessage) {
|
||||
get().addMessage(channelId, savedMessage);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
addMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
@@ -103,10 +169,34 @@ export const useChatStore = create((set, get) => ({
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
newMessages.set(channelId, [...current, message]);
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
addRealtimeMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
return { messages: newMessages, realtimeMessageEvents: newEvents };
|
||||
});
|
||||
},
|
||||
updateMessage: (message) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || message.dmChannelId;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import type { MessageWithUser, Reaction, ReadState } from '@opencord/shared';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel } from './serverStore';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
interface TypingUser {
|
||||
userId: string;
|
||||
@@ -10,6 +11,11 @@ interface TypingUser {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface RealtimeMessageEvent {
|
||||
channelId: string;
|
||||
message: MessageWithUser;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: Map<string, MessageWithUser[]>;
|
||||
currentChannelId: string | null;
|
||||
@@ -20,6 +26,7 @@ interface ChatState {
|
||||
replyTo: MessageWithUser | null;
|
||||
readStates: Map<string, string>;
|
||||
unreadChannels: Set<string>;
|
||||
realtimeMessageEvents: RealtimeMessageEvent[];
|
||||
setCurrentChannel: (channelId: string | null) => void;
|
||||
setReplyTo: (message: MessageWithUser | null) => void;
|
||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||
@@ -29,6 +36,7 @@ interface ChatState {
|
||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||
addRealtimeMessage: (channelId: string, message: MessageWithUser) => void;
|
||||
updateMessage: (message: MessageWithUser) => void;
|
||||
removeMessage: (messageId: string, channelId: string) => void;
|
||||
addReaction: (messageId: string, emoji: string) => void;
|
||||
@@ -55,6 +63,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
@@ -113,35 +122,103 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = isDmChannel(channelId);
|
||||
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
|
||||
// Generate optimistic message
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (currentUser) {
|
||||
const optimisticMessage: MessageWithUser = {
|
||||
id: tempId,
|
||||
channelId: isDm ? '' : channelId,
|
||||
userId: currentUser.id,
|
||||
content,
|
||||
replyToId: replyToId ?? null,
|
||||
editedAt: null,
|
||||
createdAt: Date.now(),
|
||||
user: currentUser,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
};
|
||||
if (isDm) {
|
||||
(optimisticMessage as any).dmChannelId = channelId;
|
||||
}
|
||||
// Add optimistic message immediately
|
||||
get().addMessage(channelId, optimisticMessage);
|
||||
|
||||
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
|
||||
if (isDm) {
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === channelId
|
||||
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
|
||||
: dm
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
set({ replyTo: null });
|
||||
// Message will arrive via WebSocket
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
}
|
||||
// Real message will arrive via WebSocket and replace the temp one
|
||||
} catch {
|
||||
// Rollback: remove the optimistic message on failure
|
||||
get().removeMessage(tempId, channelId);
|
||||
}
|
||||
},
|
||||
|
||||
editMessage: async (messageId: string, content: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
// Optimistic: update content locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const originalMessage = messages?.find(m => m.id === messageId);
|
||||
if (originalMessage) {
|
||||
get().updateMessage({ ...originalMessage, content, editedAt: Date.now() });
|
||||
}
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
// Real update will arrive via WebSocket
|
||||
} catch {
|
||||
// Rollback: restore the original message on failure
|
||||
if (originalMessage) {
|
||||
get().updateMessage(originalMessage);
|
||||
}
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
|
||||
deleteMessage: async (messageId: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
// Optimistic: remove locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const savedMessage = messages?.find(m => m.id === messageId);
|
||||
get().removeMessage(messageId, channelId);
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Real deletion will arrive via WebSocket (already removed locally)
|
||||
} catch {
|
||||
// Rollback: re-add the message on failure
|
||||
if (savedMessage) {
|
||||
get().addMessage(channelId, savedMessage);
|
||||
}
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
|
||||
addMessage: (channelId: string, message: MessageWithUser) => {
|
||||
@@ -150,11 +227,35 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id)) return state;
|
||||
newMessages.set(channelId, [...current, message]);
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
|
||||
addRealtimeMessage: (channelId: string, message: MessageWithUser) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id)) return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
return { messages: newMessages, realtimeMessageEvents: newEvents };
|
||||
});
|
||||
},
|
||||
|
||||
updateMessage: (message: MessageWithUser) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
||||
|
||||
@@ -19,6 +19,15 @@ export const useServerStore = create((set, get) => ({
|
||||
addDmChannel: (channel) => set((state) => ({
|
||||
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
|
||||
})),
|
||||
removeDmChannel: (id) => set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
})),
|
||||
closeDm: async (id) => {
|
||||
await api.dm.close(id);
|
||||
set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
}));
|
||||
},
|
||||
loadServers: async () => {
|
||||
try {
|
||||
const servers = await api.servers.list();
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ServerState {
|
||||
setRoles: (roles: Role[]) => void;
|
||||
setDmChannels: (channels: DmChannel[]) => void;
|
||||
addDmChannel: (channel: DmChannel) => void;
|
||||
removeDmChannel: (id: string) => void;
|
||||
closeDm: (id: string) => Promise<void>;
|
||||
loadServers: () => Promise<void>;
|
||||
loadServerDetail: (serverId: string) => Promise<void>;
|
||||
loadDmChannels: () => Promise<void>;
|
||||
@@ -60,6 +62,17 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
|
||||
})),
|
||||
|
||||
removeDmChannel: (id) => set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
})),
|
||||
|
||||
closeDm: async (id) => {
|
||||
await api.dm.close(id);
|
||||
set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
}));
|
||||
},
|
||||
|
||||
loadServers: async () => {
|
||||
try {
|
||||
const servers = await api.servers.list();
|
||||
|
||||
@@ -102,4 +102,18 @@ export const useSocialStore = create((set, get) => ({
|
||||
requests: state.requests.filter(r => r.id !== requestId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler when the other user removes us as a friend
|
||||
removeFriendLocally: (userId) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== userId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler on presence_update to keep friend status live
|
||||
updateFriendPresence: (userId, status) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.map(f =>
|
||||
f.id === userId ? { ...f, status } : f
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SocialState {
|
||||
searchUsers: (query: string) => Promise<User[]>;
|
||||
addIncomingRequest: (request: FriendRequest) => void;
|
||||
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
||||
updateFriendPresence: (userId: string, status: string) => void;
|
||||
removeFriendLocally: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
@@ -121,4 +123,20 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
requests: state.requests.filter(r => r.id !== requestId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Called from WS handler when the other user removes us as a friend
|
||||
removeFriendLocally: (userId: string) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== userId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Called from WS handler on presence_update to keep friend status live
|
||||
updateFriendPresence: (userId: string, status: string) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.map(f =>
|
||||
f.id === userId ? { ...f, status: status as Friend['status'] } : f
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,40 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
export const useUIStore = create((set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile)
|
||||
return;
|
||||
set({ isMobile, sidebarOpen: !isMobile, memberListOpen: !isMobile });
|
||||
},
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
export const useUIStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile)
|
||||
return;
|
||||
if (isMobile) {
|
||||
set({ isMobile, sidebarOpen: false, memberListOpen: false });
|
||||
}
|
||||
else {
|
||||
// On desktop transition, restore sidebarOpen but leave memberListOpen
|
||||
// at its persisted/toggled value — don't override user preference
|
||||
set({ isMobile, sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User } from '@opencord/shared';
|
||||
|
||||
type ModalType =
|
||||
@@ -43,48 +44,65 @@ interface UIState {
|
||||
setPipCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile) return;
|
||||
set({ isMobile, sidebarOpen: !isMobile, memberListOpen: !isMobile });
|
||||
},
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile) return;
|
||||
if (isMobile) {
|
||||
set({ isMobile, sidebarOpen: false, memberListOpen: false });
|
||||
} else {
|
||||
// On desktop transition, restore sidebarOpen but leave memberListOpen
|
||||
// at its persisted/toggled value — don't override user preference
|
||||
set({ isMobile, sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user