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:
Jannis Braun
2026-02-22 22:15:11 +01:00
parent 708bdf5468
commit 6b8fd44a3a
35 changed files with 1002 additions and 262 deletions
+56 -38
View File
@@ -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,
}),
}
)
);