fix: repair invite links, social features, messaging + Discord UI overhaul

Phase 1 - Feature Repair:
- Fix member kick/leave: add missing db.delete() call in servers.ts
- Stabilize invite codes: return existing code instead of regenerating
- Fix user search: use LIKE instead of exact match in social.ts
- Wire DM button on FriendsPage to create/navigate to DM channels
- Add cancel outgoing friend request (DELETE endpoint + frontend)
- Add accept/decline friend request actions with WS real-time events
- Fix replyToId persistence in message creation
- Hydrate reactions and replyTo in message queries
- Add joinByCode to API client and serverStore
- Add friend_request_received/accepted WebSocket events

Phase 2 - Discord UI Overhaul:
- Remove stray borders between layout columns
- Replace shadow-sm with shadow-header on content headers
- Replace all bg-gray-*/text-gray-* with Discord color tokens
- Ensure flat color contrast (#1E1F22, #2B2D31, #313338)

Testing:
- Set up vitest + @testing-library/react + jsdom
- Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing)
- Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
Jannis Braun
2026-02-18 05:34:45 +01:00
parent 4fd17084a5
commit 5ef502f2e3
82 changed files with 4906 additions and 552 deletions
+65 -3
View File
@@ -1,18 +1,25 @@
import { create } from 'zustand';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
export const useChatStore = create((set, get) => ({
messages: new Map(),
currentChannelId: null,
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
replyTo: null,
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId) => {
if (get().messages.has(channelId))
return;
set({ isLoading: true });
try {
const messages = await api.channels.messages(channelId);
const isDm = useUIStore.getState().showDms;
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages);
@@ -35,7 +42,10 @@ export const useChatStore = create((set, get) => ({
if (!oldestMessage)
return false;
try {
const olderMessages = await api.channels.messages(channelId, oldestMessage.id);
const isDm = useUIStore.getState().showDms;
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId) ?? [];
@@ -51,7 +61,15 @@ export const useChatStore = create((set, get) => ({
}
},
sendMessage: async (channelId, content, attachmentIds) => {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds });
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
if (isDm) {
await api.dm.sendMessage(channelId, { content });
}
else {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
}
set({ replyTo: null });
// Message will arrive via WebSocket
},
editMessage: async (messageId, content) => {
@@ -93,6 +111,50 @@ export const useChatStore = create((set, get) => ({
return { messages: newMessages };
});
},
addReaction: (messageId, emoji) => {
wsSend({ type: 'reaction_add', messageId, emoji });
},
removeReaction: (messageId, emoji) => {
wsSend({ type: 'reaction_remove', messageId, emoji });
},
onReactionAdded: (messageId, reaction) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex];
newMsgs[msgIndex] = {
...oldMsg,
reactions: [...(oldMsg.reactions || []), reaction],
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
onReactionRemoved: (messageId, userId, emoji) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex];
newMsgs[msgIndex] = {
...oldMsg,
reactions: (oldMsg.reactions || []).filter(r => !(r.userId === userId && r.emoji === emoji)),
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
setTyping: (channelId, userId, username) => {
set((state) => {
const newTyping = new Map(state.typingUsers);
+87 -10
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import type { MessageWithUser } from '@opencord/shared';
import type { MessageWithUser, Reaction } from '@opencord/shared';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { useUIStore } from './uiStore';
interface TypingUser {
userId: string;
@@ -14,7 +16,10 @@ interface ChatState {
typingUsers: Map<string, TypingUser[]>;
hasMore: Map<string, boolean>;
isLoading: boolean;
loadError: string | null;
replyTo: MessageWithUser | null;
setCurrentChannel: (channelId: string | null) => void;
setReplyTo: (message: MessageWithUser | null) => void;
loadMessages: (channelId: string) => Promise<void>;
loadMoreMessages: (channelId: string) => Promise<boolean>;
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
@@ -23,6 +28,10 @@ interface ChatState {
addMessage: (channelId: string, message: MessageWithUser) => void;
updateMessage: (message: MessageWithUser) => void;
removeMessage: (messageId: string, channelId: string) => void;
addReaction: (messageId: string, emoji: string) => void;
removeReaction: (messageId: string, emoji: string) => void;
onReactionAdded: (messageId: string, reaction: any) => void;
onReactionRemoved: (messageId: string, userId: string, emoji: string) => void;
setTyping: (channelId: string, userId: string, username: string) => void;
clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[];
@@ -35,23 +44,30 @@ export const useChatStore = create<ChatState>((set, get) => ({
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
loadError: null,
replyTo: null,
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId: string) => {
if (get().messages.has(channelId)) return;
set({ isLoading: true });
set({ isLoading: true, loadError: null });
try {
const messages = await api.channels.messages(channelId);
const isDm = useUIStore.getState().showDms;
const messages = isDm
? await api.dm.messages(channelId)
: await api.channels.messages(channelId);
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages);
newMessages.set(channelId, messages as MessageWithUser[]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, messages.length >= 50);
return { messages: newMessages, hasMore: newHasMore, isLoading: false };
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
});
} catch {
set({ isLoading: false });
} catch (err) {
set({ isLoading: false, loadError: (err as Error).message || 'Failed to load messages' });
}
},
@@ -64,11 +80,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (!oldestMessage) return false;
try {
const olderMessages = await api.channels.messages(channelId, oldestMessage.id);
const isDm = useUIStore.getState().showDms;
const olderMessages = isDm
? await api.dm.messages(channelId, oldestMessage.id)
: await api.channels.messages(channelId, oldestMessage.id);
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId) ?? [];
newMessages.set(channelId, [...olderMessages, ...current]);
newMessages.set(channelId, [...(olderMessages as MessageWithUser[]), ...current]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, olderMessages.length >= 50);
return { messages: newMessages, hasMore: newHasMore };
@@ -80,7 +100,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds });
const replyToId = get().replyTo?.id;
const isDm = useUIStore.getState().showDms;
if (isDm) {
await api.dm.sendMessage(channelId, { content });
} else {
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
}
set({ replyTo: null });
// Message will arrive via WebSocket
},
@@ -128,6 +157,54 @@ export const useChatStore = create<ChatState>((set, get) => ({
});
},
addReaction: (messageId: string, emoji: string) => {
wsSend({ type: 'reaction_add', messageId, emoji });
},
removeReaction: (messageId: string, emoji: string) => {
wsSend({ type: 'reaction_remove', messageId, emoji });
},
onReactionAdded: (messageId: string, reaction: Reaction) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex]!;
newMsgs[msgIndex] = {
...oldMsg,
reactions: [...(oldMsg.reactions || []), reaction],
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
onReactionRemoved: (messageId: string, userId: string, emoji: string) => {
set((state) => {
const newMessages = new Map(state.messages);
for (const [channelId, msgs] of newMessages.entries()) {
const msgIndex = msgs.findIndex(m => m.id === messageId);
if (msgIndex !== -1) {
const newMsgs = [...msgs];
const oldMsg = newMsgs[msgIndex]!;
newMsgs[msgIndex] = {
...oldMsg,
reactions: (oldMsg.reactions || []).filter(r => !(r.userId === userId && r.emoji === emoji)),
};
newMessages.set(channelId, newMsgs);
break;
}
}
return { messages: newMessages };
});
},
setTyping: (channelId: string, userId: string, username: string) => {
set((state) => {
const newTyping = new Map(state.typingUsers);
+24 -2
View File
@@ -5,10 +5,18 @@ export const useServerStore = create((set, get) => ({
currentServerId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
addDmChannel: (channel) => set((state) => ({
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
})),
loadServers: async () => {
try {
const servers = await api.servers.list();
@@ -25,12 +33,22 @@ export const useServerStore = create((set, get) => ({
currentServerId: serverId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
});
}
catch {
// Handle error silently
}
},
loadDmChannels: async () => {
try {
const dmChannels = await api.dm.list();
set({ dmChannels });
}
catch {
// Handle error silently
}
},
createServer: async (name, icon) => {
const server = await api.servers.create({ name, icon });
set((state) => ({ servers: [...state.servers, server] }));
@@ -102,7 +120,7 @@ export const useServerStore = create((set, get) => ({
members: state.members.filter(m => m.userId !== userId),
}));
},
populateFromReady: (servers) => {
populateFromReady: (servers, folders, dmChannels) => {
const simpleServers = servers.map(s => ({
id: s.id,
name: s.name,
@@ -111,6 +129,10 @@ export const useServerStore = create((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
set({ servers: simpleServers });
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
});
},
}));
+44 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers } from '@opencord/shared';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel } from '@opencord/shared';
import { api } from '../api/client';
interface ServerState {
@@ -7,16 +7,24 @@ interface ServerState {
currentServerId: string | null;
channels: Channel[];
members: MemberWithUser[];
roles: Role[];
folders: ServerFolder[];
dmChannels: DmChannel[];
setServers: (servers: Server[]) => void;
setCurrentServer: (serverId: string | null) => void;
setChannels: (channels: Channel[]) => void;
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel) => void;
loadServers: () => Promise<void>;
loadServerDetail: (serverId: string) => Promise<void>;
loadDmChannels: () => Promise<void>;
createServer: (name: string, icon?: string) => Promise<Server>;
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
deleteServer: (serverId: string) => Promise<void>;
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string) => Promise<Server>;
generateInvite: (serverId: string) => Promise<string>;
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
@@ -25,7 +33,7 @@ interface ServerState {
updateMemberPresence: (userId: string, status: string) => void;
addMember: (member: MemberWithUser) => void;
removeMember: (userId: string) => void;
populateFromReady: (servers: ServerWithChannelsAndMembers[]) => void;
populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
}
export const useServerStore = create<ServerState>((set, get) => ({
@@ -33,11 +41,20 @@ export const useServerStore = create<ServerState>((set, get) => ({
currentServerId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
setDmChannels: (dmChannels) => set({ dmChannels }),
addDmChannel: (channel) => set((state) => ({
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
})),
loadServers: async () => {
try {
@@ -55,12 +72,22 @@ export const useServerStore = create<ServerState>((set, get) => ({
currentServerId: serverId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list
});
} catch {
// Handle error silently
}
},
loadDmChannels: async () => {
try {
const dmChannels = await api.dm.list();
set({ dmChannels });
} catch {
// Handle error silently
}
},
createServer: async (name: string, icon?: string) => {
const server = await api.servers.create({ name, icon });
set((state) => ({ servers: [...state.servers, server] }));
@@ -90,6 +117,15 @@ export const useServerStore = create<ServerState>((set, get) => ({
});
},
joinByCode: async (inviteCode: string) => {
const server = await api.servers.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, server] };
});
return server;
},
generateInvite: async (serverId: string) => {
const result = await api.servers.invite(serverId);
return result.inviteCode;
@@ -144,7 +180,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
populateFromReady: (servers: ServerWithChannelsAndMembers[]) => {
populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => {
const simpleServers: Server[] = servers.map(s => ({
id: s.id,
name: s.name,
@@ -153,6 +189,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
set({ servers: simpleServers });
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dmChannels || []
});
},
}));
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { api } from '../api/client';
export const useSocialStore = create((set, get) => ({
friends: [],
requests: [],
isLoading: false,
error: null,
loadFriends: async () => {
set({ isLoading: true, error: null });
try {
const friends = await api.social.friends();
set({ friends, isLoading: false });
}
catch (err) {
set({ error: err.message, isLoading: false });
}
},
loadRequests: async () => {
set({ isLoading: true, error: null });
try {
const requests = await api.social.requests();
set({ requests, isLoading: false });
}
catch (err) {
set({ error: err.message, isLoading: false });
}
},
sendFriendRequest: async (username) => {
set({ isLoading: true, error: null });
try {
await api.social.sendRequest(username);
await get().loadRequests();
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
updateFriendRequest: async (id, status) => {
set({ isLoading: true, error: null });
try {
await api.social.updateRequest(id, status);
await get().loadRequests();
if (status === 'accepted') {
await get().loadFriends();
}
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
removeFriend: async (id) => {
set({ isLoading: true, error: null });
try {
await api.social.removeFriend(id);
set((state) => ({
friends: state.friends.filter((f) => f.id !== id),
isLoading: false,
}));
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
searchUsers: async (query) => {
try {
return await api.social.search(query);
}
catch (err) {
console.error('Failed to search users:', err);
return [];
}
},
}));
+124
View File
@@ -0,0 +1,124 @@
import { create } from 'zustand';
import type { Friend, FriendRequest, User } from '@opencord/shared';
import { api } from '../api/client';
interface SocialState {
friends: Friend[];
requests: FriendRequest[];
isLoading: boolean;
error: string | null;
loadFriends: () => Promise<void>;
loadRequests: () => Promise<void>;
sendFriendRequest: (username: string) => Promise<void>;
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
cancelFriendRequest: (id: string) => Promise<void>;
removeFriend: (id: string) => Promise<void>;
searchUsers: (query: string) => Promise<User[]>;
addIncomingRequest: (request: FriendRequest) => void;
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
}
export const useSocialStore = create<SocialState>((set, get) => ({
friends: [],
requests: [],
isLoading: false,
error: null,
loadFriends: async () => {
set({ isLoading: true, error: null });
try {
const friends = await api.social.friends();
set({ friends, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
loadRequests: async () => {
set({ isLoading: true, error: null });
try {
const requests = await api.social.requests();
set({ requests, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
sendFriendRequest: async (username: string) => {
set({ isLoading: true, error: null });
try {
await api.social.sendRequest(username);
await get().loadRequests();
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => {
set({ isLoading: true, error: null });
try {
await api.social.updateRequest(id, status);
await get().loadRequests();
if (status === 'accepted') {
await get().loadFriends();
}
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
cancelFriendRequest: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.social.cancelRequest(id);
set((state) => ({
requests: state.requests.filter(r => r.id !== id),
isLoading: false,
}));
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
removeFriend: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.social.removeFriend(id);
set((state) => ({
friends: state.friends.filter((f) => f.id !== id),
isLoading: false,
}));
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
throw err;
}
},
searchUsers: async (query: string) => {
try {
return await api.social.search(query);
} catch (err) {
console.error('Failed to search users:', err);
return [];
}
},
// Called from WS handler when another user sends you a friend request
addIncomingRequest: (request: FriendRequest) => {
set((state) => {
if (state.requests.find(r => r.id === request.id)) return state;
return { requests: [...state.requests, request] };
});
},
// Called from WS handler when someone accepts your friend request
addFriendFromAccepted: (friend: Friend, requestId: string) => {
set((state) => ({
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
requests: state.requests.filter(r => r.id !== requestId),
}));
},
}));
+10
View File
@@ -7,6 +7,10 @@ export const useUIStore = create((set) => ({
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 }),
@@ -19,4 +23,10 @@ export const useUIStore = create((set) => ({
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 }
}),
}));
+18
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { User } from '@opencord/shared';
type ModalType =
| 'createServer'
@@ -18,6 +19,10 @@ interface UIState {
isMobile: boolean;
showDms: boolean;
imagePreviewUrl: string | null;
userProfilePopout: {
user: User | null;
position: { top: number; left: number } | null;
};
toggleSidebar: () => void;
toggleMemberList: () => void;
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
@@ -26,6 +31,8 @@ interface UIState {
setShowDms: (show: boolean) => void;
openImagePreview: (url: string) => void;
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
closeUserProfile: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -36,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
isMobile: false,
showDms: false,
imagePreviewUrl: null,
userProfilePopout: {
user: null,
position: null,
},
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
@@ -53,4 +64,11 @@ export const useUIStore = create<UIState>((set) => ({
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 }
}),
}));
+3 -1
View File
@@ -6,6 +6,7 @@ export const useVoiceStore = create((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
setVoiceUsers: (channelId, userIds) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
@@ -32,7 +33,8 @@ export const useVoiceStore = create((set, get) => ({
});
},
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
toggleMute: () => set((state) => ({ isMuted: !state.isMuted })),
setParticipants: (participants) => set({ participants }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
+9 -3
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { ParticipantInfo } from '../hooks/useLiveKit';
interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds
@@ -7,14 +8,16 @@ interface VoiceState {
isDeafened: boolean;
isCameraOn: boolean;
isScreenSharing: boolean;
participants: ParticipantInfo[];
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void;
toggleMute: () => void;
toggleDeafen: () => void;
setParticipants: (participants: ParticipantInfo[]) => void;
toggleMic: () => void;
toggleCamera: () => void;
toggleScreenShare: () => void;
toggleDeafen: () => void;
getVoiceUsers: (channelId: string) => string[];
reset: () => void;
}
@@ -26,6 +29,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
setVoiceUsers: (channelId, userIds) => {
set((state) => {
@@ -57,7 +61,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
toggleMute: () => set((state) => ({ isMuted: !state.isMuted })),
setParticipants: (participants) => set({ participants }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),