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:
@@ -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 }),
|
||||
}));
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user