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
+109 -19
View File
@@ -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;
+120 -19
View File
@@ -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;
+9
View File
@@ -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();
+13
View File
@@ -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();
+14
View File
@@ -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
),
}));
},
}));
+18
View File
@@ -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
),
}));
},
}));
+58 -39
View File
@@ -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,
}),
}
)
);
+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,
}),
}
)
);