chore: remove 62 tsc emit artifacts from src/, add noEmit to tsconfig

tsc was emitting compiled .js files directly into packages/web/src/
alongside the .tsx source files because noEmit was not set. These
artifacts were never used — Vite compiles from .tsx source directly.

- Add noEmit: true to packages/web/tsconfig.json (tsc = type-check only)
- Delete all 62 orphaned .js files from src/ (-6,129 lines)
- Add packages/web/src/**/*.js to .gitignore as safeguard
This commit is contained in:
Jannis Braun
2026-02-23 00:54:51 +01:00
parent 5747267a6b
commit 176f4db27e
64 changed files with 3 additions and 6129 deletions
-62
View File
@@ -1,62 +0,0 @@
import { create } from 'zustand';
import { api } from '../api/client';
export const useAuthStore = create((set, get) => ({
token: localStorage.getItem('opencord_token'),
user: null,
isLoading: false,
error: null,
login: async (username, password) => {
set({ isLoading: true, error: null });
try {
const response = await api.auth.login({ username, password });
localStorage.setItem('opencord_token', response.token);
set({ token: response.token, user: response.user, isLoading: false });
}
catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
throw err;
}
},
register: async (username, password, displayName) => {
set({ isLoading: true, error: null });
try {
const response = await api.auth.register({ username, password, displayName });
localStorage.setItem('opencord_token', response.token);
set({ token: response.token, user: response.user, isLoading: false });
}
catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Registration failed' });
throw err;
}
},
logout: () => {
localStorage.removeItem('opencord_token');
set({ token: null, user: null });
},
loadUser: async () => {
const token = get().token;
if (!token)
return;
set({ isLoading: true });
try {
const user = await api.users.me();
set({ user, isLoading: false });
}
catch {
localStorage.removeItem('opencord_token');
set({ token: null, user: null, isLoading: false });
}
},
updateProfile: async (data) => {
try {
const user = await api.users.update(data);
set({ user });
}
catch (err) {
set({ error: err instanceof Error ? err.message : 'Update failed' });
throw err;
}
},
setUser: (user) => set({ user }),
clearError: () => set({ error: null }),
}));
-351
View File
@@ -1,351 +0,0 @@
import { create } from 'zustand';
import { api } from '../api/client';
import { wsSend } from '../hooks/useWebSocket';
import { isDmChannel, useServerStore } from './serverStore';
import { useAuthStore } from './authStore';
export const useChatStore = create((set, get) => ({
messages: new Map(),
currentChannelId: null,
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
loadError: null,
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() }),
loadMessages: async (channelId, force) => {
if (!force && get().messages.has(channelId))
return;
set({ isLoading: true, loadError: null });
try {
const isDm = isDmChannel(channelId);
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);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, messages.length >= 50);
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
});
}
catch (err) {
set({ isLoading: false, loadError: err.message || 'Failed to load messages' });
}
},
loadMoreMessages: async (channelId) => {
const existing = get().messages.get(channelId);
if (!existing || existing.length === 0)
return false;
if (!get().hasMore.get(channelId))
return false;
const oldestMessage = existing[0];
if (!oldestMessage)
return false;
try {
const isDm = isDmChannel(channelId);
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]);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, olderMessages.length >= 50);
return { messages: newMessages, hasMore: newHasMore };
});
return olderMessages.length > 0;
}
catch {
return false;
}
},
sendMessage: async (channelId, content, attachmentIds) => {
const replyToId = get().replyTo?.id;
const isDm = isDmChannel(channelId);
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 });
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);
// 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 });
}
} catch {
// Rollback: restore the original message on failure
if (originalMessage) {
get().updateMessage(originalMessage);
}
}
},
deleteMessage: async (messageId, channelId) => {
const isDm = isDmChannel(channelId);
// 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);
}
}
},
addMessage: (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]);
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;
if (!channelKey)
return;
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelKey);
if (!current)
return state;
newMessages.set(channelKey, current.map(m => m.id === message.id ? message : m));
return { messages: newMessages };
});
},
removeMessage: (messageId, channelId) => {
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId);
if (!current)
return state;
newMessages.set(channelId, current.filter(m => m.id !== messageId));
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);
const current = newTyping.get(channelId) ?? [];
const filtered = current.filter(t => t.userId !== userId);
filtered.push({ userId, username, timestamp: Date.now() });
newTyping.set(channelId, filtered);
return { typingUsers: newTyping };
});
// Auto-clear after 5 seconds
setTimeout(() => {
get().clearTyping(channelId, userId);
}, 5000);
},
clearTyping: (channelId, userId) => {
set((state) => {
const newTyping = new Map(state.typingUsers);
const current = newTyping.get(channelId);
if (!current)
return state;
newTyping.set(channelId, current.filter(t => t.userId !== userId));
return { typingUsers: newTyping };
});
},
getMessages: (channelId) => {
return get().messages.get(channelId) ?? [];
},
getTypingUsers: (channelId) => {
const users = get().typingUsers.get(channelId) ?? [];
const now = Date.now();
return users.filter(t => now - t.timestamp < 5000);
},
setReadStates: (readStates, channelLastMessageIds) => {
const rsMap = new Map();
for (const rs of readStates) {
rsMap.set(rs.channelId, rs.lastReadMessageId);
}
const unread = new Set();
for (const [channelId, lastMsgId] of channelLastMessageIds) {
const lastRead = rsMap.get(channelId);
if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) {
unread.add(channelId);
}
}
set({ readStates: rsMap, unreadChannels: unread });
},
markChannelUnread: (channelId) => {
set((state) => {
if (state.unreadChannels.has(channelId))
return state;
const newUnread = new Set(state.unreadChannels);
newUnread.add(channelId);
return { unreadChannels: newUnread };
});
},
ackChannel: (channelId) => {
const msgs = get().messages.get(channelId);
if (!msgs || msgs.length === 0)
return;
const lastMsg = msgs[msgs.length - 1];
if (!lastMsg)
return;
const messageId = lastMsg.id;
// Update local state immediately
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
// Send to server
wsSend({ type: 'channel_ack', channelId, messageId });
},
onChannelAck: (channelId, messageId) => {
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
const newUnread = new Set(state.unreadChannels);
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
},
}));
-194
View File
@@ -1,194 +0,0 @@
import { create } from 'zustand';
import { api } from '../api/client';
export const useServerStore = create((set, get) => ({
servers: [],
currentServerId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
channelToServerMap: new Map(),
channelLastMessageIds: new Map(),
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)]
})),
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();
set({ servers });
}
catch {
// Silently fail - will be populated from WS ready
}
},
loadServerDetail: async (serverId) => {
try {
const detail = await api.servers.get(serverId);
set({
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] }));
return server;
},
updateServer: async (serverId, data) => {
const updated = await api.servers.update(serverId, data);
set((state) => ({
servers: state.servers.map(s => s.id === serverId ? { ...s, ...updated } : s),
}));
},
deleteServer: async (serverId) => {
await api.servers.delete(serverId);
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
}));
},
joinServer: async (serverId, inviteCode) => {
const server = await api.servers.join(serverId, { inviteCode });
set((state) => {
if (state.servers.find(s => s.id === server.id))
return state;
return { servers: [...state.servers, server] };
});
},
joinByCode: async (inviteCode) => {
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) => {
const result = await api.servers.invite(serverId);
return result.inviteCode;
},
createChannel: async (serverId, name, type, topic) => {
const channel = await api.channels.create(serverId, { name, type, topic });
set((state) => ({
channels: [...state.channels, channel].sort((a, b) => a.position - b.position),
}));
return channel;
},
deleteChannel: async (channelId) => {
await api.channels.delete(channelId);
set((state) => ({
channels: state.channels.filter(c => c.id !== channelId),
}));
},
addServer: (server) => {
set((state) => {
if (state.servers.find(s => s.id === server.id))
return state;
return { servers: [...state.servers, server] };
});
},
removeServer: (serverId) => {
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
}));
},
updateMemberPresence: (userId, status) => {
set((state) => ({
members: state.members.map(m => m.userId === userId ? { ...m, user: { ...m.user, status: status } } : m),
}));
},
addMember: (member) => {
set((state) => ({
members: [...state.members.filter(m => m.userId !== member.userId), member],
}));
},
removeMember: (userId) => {
set((state) => ({
members: state.members.filter(m => m.userId !== userId),
}));
},
populateFromReady: (servers, folders, dmChannels) => {
const simpleServers = servers.map(s => ({
id: s.id,
name: s.name,
icon: s.icon,
ownerId: s.ownerId,
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
// Build channel→server map and channel→lastMessageId map
const channelToServerMap = new Map();
const channelLastMessageIds = new Map();
for (const srv of servers) {
for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
}
}
}
// Also map DM channels
const dms = dmChannels || [];
for (const dm of dms) {
if (dm.lastMessage?.id) {
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
}
}
set({
servers: simpleServers,
folders: folders || [],
dmChannels: dms,
channelToServerMap,
channelLastMessageIds,
});
},
}));
/**
* Data-driven DM channel detection. Returns true if the given channelId
* belongs to a DM channel. Authoritative because dmChannels is populated
* from the WS ready event and DM/server channel IDs never overlap.
*/
export function isDmChannel(channelId) {
const dmChannels = useServerStore.getState().dmChannels;
if (dmChannels.length > 0) {
return dmChannels.some(dm => dm.id === channelId);
}
// Before WS ready populates dmChannels, fall back to URL path
if (typeof window !== 'undefined') {
return window.location.pathname.startsWith('/channels/@me/');
}
return false;
}
-119
View File
@@ -1,119 +0,0 @@
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;
}
},
cancelFriendRequest: async (id) => {
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.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 [];
}
},
// Called from WS handler when another user sends you a friend request
addIncomingRequest: (request) => {
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, requestId) => {
set((state) => ({
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
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
),
}));
},
}));
-59
View File
@@ -1,59 +0,0 @@
import { create } from 'zustand';
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,
}),
}
)
);
-242
View File
@@ -1,242 +0,0 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { AudioManager } from '../audio/AudioManager';
export const useVoiceStore = create()(persist((set, get) => ({
voiceUsers: new Map(),
currentVoiceChannelId: null,
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
inputDeviceId: 'default',
outputDeviceId: 'default',
focusedParticipantId: null,
videoQuality: '720p60',
participantVolumes: new Map(),
setParticipantVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.participantVolumes);
newMap.set(userId, volume);
return { participantVolumes: newMap };
});
},
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
// Stream widget state
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
streamAttenuationEnabled: false,
streamAttenuationStrength: 50,
setStreamVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.set(userId, volume);
return { streamVolumes: newMap };
});
},
setStreamMute: (userId, muted) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.set(userId, muted);
return { streamMutes: newMap };
});
},
watchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.add(userId);
return { watchingStreams: newSet };
});
},
unwatchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.delete(userId);
return { watchingStreams: newSet };
});
},
clearStreamVolume: (userId) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.delete(userId);
return { streamVolumes: newMap };
});
},
clearStreamMute: (userId) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.delete(userId);
return { streamMutes: newMap };
});
},
setStreamAttenuationEnabled: (enabled) => set({ streamAttenuationEnabled: enabled }),
setStreamAttenuationStrength: (strength) => set({ streamAttenuationStrength: strength }),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
setIncomingCall: (call) => set({ incomingCall: call }),
setOutgoingCall: (call) => set({ outgoingCall: call }),
setActiveDmCall: (call) => set({ activeDmCall: call }),
setVoiceUsers: (channelId, userIds) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
newMap.set(channelId, userIds);
return { voiceUsers: newMap };
});
},
addVoiceUser: (channelId, userId) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
const current = newMap.get(channelId) ?? [];
if (!current.includes(userId)) {
newMap.set(channelId, [...current, userId]);
}
return { voiceUsers: newMap };
});
},
removeVoiceUser: (channelId, userId) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
const current = newMap.get(channelId) ?? [];
newMap.set(channelId, current.filter(id => id !== userId));
return { voiceUsers: newMap };
});
},
setCurrentVoiceChannel: (channelId) => set({
currentVoiceChannelId: channelId,
activeDmCall: null // Clear active DM call when joining a server channel
}),
setParticipants: (participants) => set({ participants }),
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
setInputVolume: (volume) => {
set({ inputVolume: volume });
AudioManager.getInstance().setInputVolume(volume);
},
setOutputVolume: (volume) => set({ outputVolume: volume }),
setInputDevice: (deviceId) => set({ inputDeviceId: deviceId }),
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
noiseSuppression: true,
echoCancellation: true,
autoGainControl: false,
rnnoiseEnabled: true,
setEchoCancellation: (enabled) => set({ echoCancellation: enabled }),
setAutoGainControl: (enabled) => set({ autoGainControl: enabled }),
setRnnoiseEnabled: (enabled) => set({ rnnoiseEnabled: enabled }),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
const newSet = new Set(state.deafenedUserIds);
if (deafened)
newSet.add(userId);
else
newSet.delete(userId);
return { deafenedUserIds: newSet };
});
},
voiceUserStates: new Map(),
setVoiceUserStatus: (userId, isMuted, isDeafened) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.set(userId, { isMuted, isDeafened });
return { voiceUserStates: newMap };
});
},
clearVoiceUserStatus: (userId) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.delete(userId);
return { voiceUserStates: newMap };
});
},
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
leaveVoice: () => set({
currentVoiceChannelId: null,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
focusedParticipantId: null,
activeDmCall: null,
outgoingCall: null,
deafenedUserIds: new Set(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
reset: () => set({
voiceUsers: new Map(),
currentVoiceChannelId: null,
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
inputDeviceId: 'default',
outputDeviceId: 'default',
focusedParticipantId: null,
participantVolumes: new Map(),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
deafenedUserIds: new Set(),
voiceUserStates: new Map(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
}), {
name: 'opencord-voice-settings',
version: 4,
migrate: (persistedState, version) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
}
if (version < 2) {
persistedState.echoCancellation = true;
persistedState.autoGainControl = false;
}
if (version < 4) {
// v4: RNNoise on by default, browser NS is no longer user-configurable
persistedState.rnnoiseEnabled = true;
persistedState.noiseSuppression = true;
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
// Only persist these keys. Maps and Sets are complex to serialize.
// noiseSuppression intentionally excluded — always true, managed by AudioManager.
partialize: (state) => ({
currentVoiceChannelId: state.currentVoiceChannelId,
isMuted: state.isMuted,
isDeafened: state.isDeafened,
inputVolume: state.inputVolume,
outputVolume: state.outputVolume,
inputDeviceId: state.inputDeviceId,
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
echoCancellation: state.echoCancellation,
autoGainControl: state.autoGainControl,
rnnoiseEnabled: state.rnnoiseEnabled,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),
}));