feat: real-time sync, notification & social system overhaul
- Add WS events: dm_channel_created, dm_channel_closed, friend_removed, channel_created/updated/deleted, server_updated - Fix first-ever DM: broadcast dm_channel_created to recipient - Add DELETE /api/dm/:id for closing DMs with re-open support - Wire Close DM button in sidebar - Fix dm_message_created for unknown channels (safety net) - Broadcast friend_removed on friend deletion - Sound system: track realtimeMessageEvents separately from API loads, play notification for messages in all channels, not just current - Optimistic updates: send/edit/delete messages appear instantly with rollback on failure, temp message deduplication on WS echo - Channel CRUD broadcasts to all server members - Server update broadcast on PATCH - Server join registers user in connectionManager immediately - Reconnect: only reload current channel, preserve other channel caches - Activity panel, right panel, member list toggle components
This commit is contained in:
@@ -17,5 +17,5 @@ function AuthRedirect({ children }) {
|
||||
return _jsx(_Fragment, { children: children });
|
||||
}
|
||||
export function App() {
|
||||
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/channels/@me/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/join/:inviteCode", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] }));
|
||||
return (_jsxs(Routes, { children: [_jsx(Route, { path: "/login", element: _jsx(AuthRedirect, { children: _jsx(LoginPage, {}) }) }), _jsx(Route, { path: "/register", element: _jsx(AuthRedirect, { children: _jsx(RegisterPage, {}) }) }), _jsx(Route, { path: "/channels/:serverId/:channelId?", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/join/:inviteCode", element: _jsx(ProtectedRoute, { children: _jsx(AppLayout, {}) }) }), _jsx(Route, { path: "/", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/channels/@me", replace: true }) })] }));
|
||||
}
|
||||
|
||||
@@ -44,14 +44,6 @@ export function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/channels/@me/:channelId?"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/join/:inviteCode"
|
||||
element={
|
||||
|
||||
@@ -91,6 +91,7 @@ export const api = {
|
||||
dm: {
|
||||
list: () => request('GET', '/dm'),
|
||||
create: (data) => request('POST', '/dm', data),
|
||||
close: (id) => request('DELETE', `/dm/${id}`),
|
||||
messages: (id, before, limit = 50) => {
|
||||
const params = new URLSearchParams();
|
||||
if (before)
|
||||
|
||||
@@ -149,6 +149,7 @@ export const api = {
|
||||
dm: {
|
||||
list: () => request<DmChannel[]>('GET', '/dm'),
|
||||
create: (data: CreateDmRequest) => request<DmChannel>('POST', '/dm', data),
|
||||
close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`),
|
||||
messages: (id: string, before?: string, limit = 50) => {
|
||||
const params = new URLSearchParams();
|
||||
if (before) params.set('before', before);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { api } from '../../api/client';
|
||||
export function FriendsPage() {
|
||||
@@ -13,8 +13,6 @@ export function FriendsPage() {
|
||||
const [addStatus, setAddStatus] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore();
|
||||
useEffect(() => {
|
||||
loadFriends();
|
||||
@@ -61,7 +59,7 @@ export function FriendsPage() {
|
||||
return (_jsxs("div", { className: "flex-1 p-8", children: [_jsx("h2", { className: "text-base font-bold text-discord-text-primary uppercase mb-2", children: "Add Friend" }), _jsx("p", { className: "text-sm text-discord-text-muted mb-4", children: "You can add friends with their Opencord username." }), _jsxs("form", { onSubmit: handleAddFriend, className: "relative mb-8", children: [_jsx("input", { type: "text", placeholder: "You can add a friend with their username", value: addUsername, onChange: (e) => setAddUsername(e.target.value), className: "w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" }), _jsx("button", { type: "submit", disabled: !addUsername.trim() || isLoading, className: "absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors", children: "Send Friend Request" })] }), addStatus && (_jsx("div", { className: `text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-text-positive border-discord-green/20 bg-discord-green/5' : 'text-discord-text-danger border-discord-red/20 bg-discord-red/5'}`, children: addStatus.message }))] }));
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsx("button", { onClick: toggleMemberList, className: `w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Activity Panel", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), renderTabContent()] }));
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsx(MemberListToggleButton, {}) })] }), renderTabContent()] }));
|
||||
}
|
||||
function TabButton({ children, active, onClick }) {
|
||||
return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children }));
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { api } from '../../api/client';
|
||||
import type { Friend, FriendRequest } from '@opencord/shared';
|
||||
@@ -16,8 +16,6 @@ export function FriendsPage() {
|
||||
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
|
||||
const {
|
||||
friends,
|
||||
@@ -207,17 +205,7 @@ export function FriendsPage() {
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleMemberList}
|
||||
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
|
||||
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title="Toggle Activity Panel"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<MemberListToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
export function ActivityPanel() {
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const loadFriends = useSocialStore((s) => s.loadFriends);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
useEffect(() => {
|
||||
loadFriends();
|
||||
}, [loadFriends]);
|
||||
const { onlineFriends, offlineFriends } = useMemo(() => {
|
||||
const online = friends.filter(f => f.status !== 'offline');
|
||||
const offline = friends.filter(f => f.status === 'offline');
|
||||
return { onlineFriends: online, offlineFriends: offline };
|
||||
}, [friends]);
|
||||
if (!memberListOpen)
|
||||
return null;
|
||||
const handleFriendClick = (e, friend) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile({
|
||||
id: friend.id,
|
||||
username: friend.username,
|
||||
displayName: friend.displayName,
|
||||
avatar: friend.avatar,
|
||||
status: friend.status,
|
||||
customStatus: friend.customStatus,
|
||||
createdAt: friend.createdAt,
|
||||
}, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
});
|
||||
};
|
||||
const renderFriend = (friend, isOffline = false) => (_jsxs("div", { onClick: (e) => handleFriendClick(e, friend), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: friend.avatar, name: friend.displayName ?? friend.username, size: 32, status: isOffline ? 'offline' : friend.status, className: isOffline ? 'opacity-60' : undefined }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : 'text-discord-text-primary'}`, children: friend.displayName ?? friend.username }), !isOffline && friend.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: friend.customStatus }))] })] }, friend.id));
|
||||
return (_jsx("div", { className: "w-60 bg-discord-bg-secondary flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4 px-2", children: "Active Now" }), onlineFriends.length === 0 && offlineFriends.length === 0 ? (_jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we'll show it here!" })] })) : (_jsxs(_Fragment, { children: [onlineFriends.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["ONLINE \u2014 ", onlineFriends.length] }), onlineFriends.map(f => renderFriend(f))] })), offlineFriends.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["OFFLINE \u2014 ", offlineFriends.length] }), offlineFriends.map(f => renderFriend(f, true))] }))] }))] }) }));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import type { Friend } from '@opencord/shared';
|
||||
|
||||
export function ActivityPanel() {
|
||||
const friends = useSocialStore((s) => s.friends);
|
||||
const loadFriends = useSocialStore((s) => s.loadFriends);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
useEffect(() => {
|
||||
loadFriends();
|
||||
}, [loadFriends]);
|
||||
|
||||
const { onlineFriends, offlineFriends } = useMemo(() => {
|
||||
const online = friends.filter(f => f.status !== 'offline');
|
||||
const offline = friends.filter(f => f.status === 'offline');
|
||||
return { onlineFriends: online, offlineFriends: offline };
|
||||
}, [friends]);
|
||||
|
||||
if (!memberListOpen) return null;
|
||||
|
||||
const handleFriendClick = (e: React.MouseEvent, friend: Friend) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(
|
||||
{
|
||||
id: friend.id,
|
||||
username: friend.username,
|
||||
displayName: friend.displayName,
|
||||
avatar: friend.avatar,
|
||||
status: friend.status,
|
||||
customStatus: friend.customStatus,
|
||||
createdAt: friend.createdAt,
|
||||
} as any,
|
||||
{
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.left - 316,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderFriend = (friend: Friend, isOffline = false) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
onClick={(e) => handleFriendClick(e, friend)}
|
||||
className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
name={friend.displayName ?? friend.username}
|
||||
size={32}
|
||||
status={isOffline ? 'offline' : friend.status}
|
||||
className={isOffline ? 'opacity-60' : undefined}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : 'text-discord-text-primary'}`}>
|
||||
{friend.displayName ?? friend.username}
|
||||
</div>
|
||||
{!isOffline && friend.customStatus && (
|
||||
<div className="text-[12px] text-discord-text-muted truncate">{friend.customStatus}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-discord-bg-secondary flex-shrink-0 overflow-y-auto select-none no-scrollbar">
|
||||
<div className="p-3">
|
||||
<h3 className="text-[20px] font-bold text-discord-text-header mb-4 px-2">Active Now</h3>
|
||||
|
||||
{onlineFriends.length === 0 && offlineFriends.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-[16px] font-bold text-discord-text-header mb-1">It's quiet for now...</div>
|
||||
<div className="text-[14px] text-discord-text-muted max-w-[200px] mx-auto">
|
||||
When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here!
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{onlineFriends.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
|
||||
ONLINE — {onlineFriends.length}
|
||||
</h3>
|
||||
{onlineFriends.map(f => renderFriend(f))}
|
||||
</div>
|
||||
)}
|
||||
{offlineFriends.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
|
||||
OFFLINE — {offlineFriends.length}
|
||||
</h3>
|
||||
{offlineFriends.map(f => renderFriend(f, true))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { ServerSidebar } from './ServerSidebar';
|
||||
import { ChannelSidebar } from './ChannelSidebar';
|
||||
import { MainContent } from './MainContent';
|
||||
import { MemberSidebar } from './MemberSidebar';
|
||||
import { RightPanel } from './RightPanel';
|
||||
import { ImagePreview } from '../chat/ImagePreview';
|
||||
import { CreateServerModal } from '../modals/CreateServer';
|
||||
import { JoinServerModal } from '../modals/JoinServer';
|
||||
@@ -73,7 +73,6 @@ export function AppLayout() {
|
||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
|
||||
@@ -181,5 +180,5 @@ export function AppLayout() {
|
||||
if (isLoading || !user) {
|
||||
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
|
||||
}
|
||||
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (memberListOpen ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we'll show it here!" })] })] }) })) : null) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
|
||||
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), _jsx(RightPanel, {})] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
|
||||
import { ServerSidebar } from './ServerSidebar';
|
||||
import { ChannelSidebar } from './ChannelSidebar';
|
||||
import { MainContent } from './MainContent';
|
||||
import { MemberSidebar } from './MemberSidebar';
|
||||
import { RightPanel } from './RightPanel';
|
||||
import { MobileNav } from './MobileNav';
|
||||
import { ImagePreview } from '../chat/ImagePreview';
|
||||
import { CreateServerModal } from '../modals/CreateServer';
|
||||
@@ -77,7 +77,7 @@ export function AppLayout() {
|
||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
|
||||
@@ -226,23 +226,7 @@ export function AppLayout() {
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex min-w-0 bg-discord-bg-primary relative">
|
||||
<MainContent />
|
||||
{serverId === '@me' ? (
|
||||
memberListOpen ? (
|
||||
<div className="w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col">
|
||||
<div className="p-4">
|
||||
<h3 className="text-[20px] font-bold text-discord-text-header mb-4">Active Now</h3>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-[16px] font-bold text-discord-text-header mb-1 text-center">It's quiet for now...</div>
|
||||
<div className="text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto">
|
||||
When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<MemberSidebar />
|
||||
)}
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ChannelSidebar() {
|
||||
? 'text-white hover:bg-discord-modifier-hover'
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [isDmUnread && (_jsx("div", { className: "absolute -left-1 w-1 h-2 bg-white rounded-r-full" })), _jsx(Avatar, { src: otherUser.avatar, name: otherUser.displayName ?? otherUser.username, size: 32, status: otherUser.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] truncate leading-tight ${currentChannelId === dm.id ? 'text-white font-medium'
|
||||
: isDmUnread ? 'text-white font-bold'
|
||||
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'}`, children: otherUser.displayName ?? otherUser.username }), dm.lastMessage && (_jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-tight mt-0.5", children: dm.lastMessage.content }))] }), _jsx("button", { onClick: (e) => { e.stopPropagation(); }, className: "opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1", title: "Close DM", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }, dm.id));
|
||||
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'}`, children: otherUser.displayName ?? otherUser.username }), dm.lastMessage && (_jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-tight mt-0.5", children: dm.lastMessage.content }))] }), _jsx("button", { onClick: (e) => { e.stopPropagation(); if (currentChannelId === dm.id) { navigate('/channels/@me'); setCurrentChannel(null); } useServerStore.getState().closeDm(dm.id); }, className: "opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1", title: "Close DM", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }, dm.id));
|
||||
}), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] })] }), floatingPanel] }));
|
||||
}
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsxs("div", { className: "h-12 flex items-center shadow-header z-10 group/header", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "flex-1 h-full px-4 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors min-w-0", children: [_jsx("span", { className: "font-bold text-[16px] text-discord-text-primary truncate leading-tight", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsx("button", { onClick: () => openModal('invite'), className: "w-10 h-full flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover transition-all flex-shrink-0", title: "Invite People", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" }) }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-3 px-2 pb-[140px] space-y-[21px] no-scrollbar", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Text Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
|
||||
|
||||
@@ -208,7 +208,15 @@ export function ChannelSidebar() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Navigate away if currently viewing this DM
|
||||
if (currentChannelId === dm.id) {
|
||||
navigate('/channels/@me');
|
||||
setCurrentChannel(null);
|
||||
}
|
||||
useServerStore.getState().closeDm(dm.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1"
|
||||
title="Close DM"
|
||||
>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,14 +14,13 @@ import { FriendsPage } from '../chat/FriendsPage';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MemberListToggleButton } from './MemberListToggleButton';
|
||||
|
||||
export function MainContent() {
|
||||
// 1. ALL HOOKS AT THE TOP
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
const currentChannelId = useChatStore((s) => s.currentChannelId);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
|
||||
@@ -147,17 +146,7 @@ export function MainContent() {
|
||||
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMemberList}
|
||||
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
|
||||
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title="Toggle Activity Panel"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<MemberListToggleButton />
|
||||
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
|
||||
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -186,8 +175,11 @@ export function MainContent() {
|
||||
if (!currentChannelId || !channel) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-discord-bg-primary">
|
||||
<div className="h-12 px-4 flex items-center shadow-header">
|
||||
<div className="h-12 px-4 flex items-center justify-between shadow-header">
|
||||
<span className="text-discord-text-muted">Select a channel</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<MemberListToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
|
||||
<p>Select a text or voice channel to get started</p>
|
||||
@@ -209,6 +201,9 @@ export function MainContent() {
|
||||
</svg>
|
||||
<span className="font-bold text-discord-text-primary">{channel.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<MemberListToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-8 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(88,101,242,0.12)_0%,transparent_70%)] animate-gradient-pulse pointer-events-none" />
|
||||
@@ -244,6 +239,9 @@ export function MainContent() {
|
||||
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
|
||||
<span className="text-xs text-discord-text-muted ml-1">{participants.length} connected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<MemberListToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden pb-20">
|
||||
@@ -289,17 +287,7 @@ export function MainContent() {
|
||||
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMemberList}
|
||||
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
|
||||
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title="Toggle Member List"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<MemberListToggleButton />
|
||||
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
|
||||
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function MemberListToggleButton() {
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
return (_jsx("button", { onClick: toggleMemberList, className: `w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
export function MemberListToggleButton() {
|
||||
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
|
||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleMemberList}
|
||||
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
|
||||
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
|
||||
}`}
|
||||
title="Toggle Member List"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { MemberSidebar } from './MemberSidebar';
|
||||
import { ActivityPanel } from './ActivityPanel';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
export function RightPanel() {
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
if (showDms || !currentServerId) {
|
||||
return _jsx(ActivityPanel, {});
|
||||
}
|
||||
return _jsx(MemberSidebar, {});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { MemberSidebar } from './MemberSidebar';
|
||||
import { ActivityPanel } from './ActivityPanel';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
|
||||
export function RightPanel() {
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
|
||||
if (showDms || !currentServerId) {
|
||||
return <ActivityPanel />;
|
||||
}
|
||||
|
||||
return <MemberSidebar />;
|
||||
}
|
||||
@@ -123,19 +123,17 @@ export function SoundController() {
|
||||
outgoingCallLoop.current = null;
|
||||
}
|
||||
});
|
||||
// 2. Listen to Chat State Changes (New Messages)
|
||||
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current)
|
||||
return;
|
||||
// Check for new messages in the current channel
|
||||
if (state.currentChannelId) {
|
||||
const messages = state.messages.get(state.currentChannelId) || [];
|
||||
const prevMessages = prevState.messages.get(state.currentChannelId) || [];
|
||||
if (messages.length > prevMessages.length) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
// Don't play sound for our own messages
|
||||
if (lastMessage && lastMessage.userId !== currentUser?.id) {
|
||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
audioManager.playSound('message');
|
||||
break; // one sound per batch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,20 +139,17 @@ export function SoundController() {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to Chat State Changes (New Messages)
|
||||
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current) return;
|
||||
|
||||
// Check for new messages in the current channel
|
||||
if (state.currentChannelId) {
|
||||
const messages = state.messages.get(state.currentChannelId) || [];
|
||||
const prevMessages = prevState.messages.get(state.currentChannelId) || [];
|
||||
|
||||
if (messages.length > prevMessages.length) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
// Don't play sound for our own messages
|
||||
if (lastMessage && lastMessage.userId !== currentUser?.id) {
|
||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
audioManager.playSound('message');
|
||||
break; // one sound per batch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ let currentToken = null;
|
||||
let isInitialized = false;
|
||||
function handleEvent(event) {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
@@ -21,10 +21,9 @@ function handleEvent(event) {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Clear stale message cache on reconnect, then re-fetch current channel
|
||||
// Only force-reload the current channel on reconnect; other channels keep their cache
|
||||
{
|
||||
const { clearAllMessages, loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
clearAllMessages();
|
||||
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
@@ -59,7 +58,7 @@ function handleEvent(event) {
|
||||
}
|
||||
break;
|
||||
case 'message_created':
|
||||
addMessage(event.message.channelId, event.message);
|
||||
addRealtimeMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
@@ -78,6 +77,7 @@ function handleEvent(event) {
|
||||
break;
|
||||
case 'presence_update':
|
||||
updateMemberPresence(event.userId, event.status);
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
break;
|
||||
case 'voice_state_update':
|
||||
if (event.action === 'join') {
|
||||
@@ -97,19 +97,31 @@ function handleEvent(event) {
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
case 'dm_message_created': {
|
||||
addMessage(event.message.dmChannelId, event.message);
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm => dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
addRealtimeMessage(event.message.dmChannelId, event.message);
|
||||
// If DM channel is unknown (first-ever message safety net), add a minimal one
|
||||
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
||||
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
||||
if (!knownDm) {
|
||||
// Construct a minimal DmChannel from the message so the sidebar shows it
|
||||
addDmCh({
|
||||
id: event.message.dmChannelId,
|
||||
createdAt: event.message.createdAt,
|
||||
members: event.message.user ? [event.message.user] : [],
|
||||
lastMessage: event.message,
|
||||
});
|
||||
} else {
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const updatedDms = currentDmChannels.map(dm => dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDms(updatedDms);
|
||||
}
|
||||
// Mark DM as unread if not currently viewing it
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
@@ -179,6 +191,57 @@ function handleEvent(event) {
|
||||
setActiveDmCall(null);
|
||||
break;
|
||||
}
|
||||
case 'dm_channel_created':
|
||||
addDmChannel(event.dmChannel);
|
||||
break;
|
||||
case 'dm_channel_closed':
|
||||
removeDmChannel(event.dmChannelId);
|
||||
break;
|
||||
case 'friend_removed': {
|
||||
const { removeFriendLocally } = useSocialStore.getState();
|
||||
removeFriendLocally(event.userId);
|
||||
break;
|
||||
}
|
||||
case 'channel_created': {
|
||||
const { currentServerId: curServerId, channels: curChannels, setChannels } = useServerStore.getState();
|
||||
if (event.serverId === curServerId) {
|
||||
if (!curChannels.find(c => c.id === event.channel.id)) {
|
||||
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_updated': {
|
||||
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId2) {
|
||||
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_deleted': {
|
||||
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId3) {
|
||||
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
|
||||
}
|
||||
{
|
||||
const { currentChannelId } = useChatStore.getState();
|
||||
if (currentChannelId === event.channelId) {
|
||||
const { channels: remainingChannels } = useServerStore.getState();
|
||||
const firstText = remainingChannels.find(c => c.type === 'text');
|
||||
if (firstText) {
|
||||
useChatStore.getState().setCurrentChannel(firstText.id);
|
||||
} else {
|
||||
useChatStore.getState().setCurrentChannel(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'server_updated': {
|
||||
const { servers: currentServers, setServers } = useServerStore.getState();
|
||||
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
|
||||
@@ -14,8 +14,8 @@ let isInitialized = false;
|
||||
|
||||
function handleEvent(event: ServerEvent): void {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||
|
||||
switch (event.type) {
|
||||
@@ -25,10 +25,9 @@ function handleEvent(event: ServerEvent): void {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Clear stale message cache on reconnect, then re-fetch current channel
|
||||
// Only force-reload the current channel on reconnect; other channels keep their cache
|
||||
{
|
||||
const { clearAllMessages, loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
clearAllMessages();
|
||||
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
@@ -64,7 +63,7 @@ function handleEvent(event: ServerEvent): void {
|
||||
break;
|
||||
|
||||
case 'message_created':
|
||||
addMessage(event.message.channelId, event.message);
|
||||
addRealtimeMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
@@ -87,6 +86,7 @@ function handleEvent(event: ServerEvent): void {
|
||||
|
||||
case 'presence_update':
|
||||
updateMemberPresence(event.userId, event.status);
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
break;
|
||||
|
||||
case 'voice_state_update':
|
||||
@@ -110,21 +110,33 @@ function handleEvent(event: ServerEvent): void {
|
||||
break;
|
||||
|
||||
case 'dm_message_created': {
|
||||
addMessage(event.message.dmChannelId, event.message as any);
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm
|
||||
);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
addRealtimeMessage(event.message.dmChannelId, event.message as any);
|
||||
// If DM channel is unknown (first-ever message safety net), add a minimal one
|
||||
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
||||
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
||||
if (!knownDm) {
|
||||
// Construct a minimal DmChannel from the message so the sidebar shows it
|
||||
addDmCh({
|
||||
id: event.message.dmChannelId,
|
||||
createdAt: event.message.createdAt,
|
||||
members: event.message.user ? [event.message.user] : [],
|
||||
lastMessage: event.message,
|
||||
});
|
||||
} else {
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const updatedDms = currentDmChannels.map(dm =>
|
||||
dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm
|
||||
);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDms(updatedDms);
|
||||
}
|
||||
// Mark DM as unread if not currently viewing it
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
@@ -207,6 +219,67 @@ function handleEvent(event: ServerEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dm_channel_created':
|
||||
addDmChannel(event.dmChannel);
|
||||
break;
|
||||
|
||||
case 'dm_channel_closed':
|
||||
removeDmChannel(event.dmChannelId);
|
||||
break;
|
||||
|
||||
case 'friend_removed': {
|
||||
const { removeFriendLocally } = useSocialStore.getState();
|
||||
removeFriendLocally(event.userId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'channel_created': {
|
||||
const { currentServerId: curServerId, channels: curChannels, setChannels } = useServerStore.getState();
|
||||
if (event.serverId === curServerId) {
|
||||
// Deduplicate: only add if not already present
|
||||
if (!curChannels.find(c => c.id === event.channel.id)) {
|
||||
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'channel_updated': {
|
||||
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId2) {
|
||||
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'channel_deleted': {
|
||||
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId3) {
|
||||
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
|
||||
}
|
||||
// If the user is currently viewing this channel, navigate away
|
||||
{
|
||||
const { currentChannelId } = useChatStore.getState();
|
||||
if (currentChannelId === event.channelId) {
|
||||
// Find the first remaining text channel to navigate to
|
||||
const { channels: remainingChannels } = useServerStore.getState();
|
||||
const firstText = remainingChannels.find(c => c.type === 'text');
|
||||
if (firstText) {
|
||||
useChatStore.getState().setCurrentChannel(firstText.id);
|
||||
} else {
|
||||
useChatStore.getState().setCurrentChannel(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'server_updated': {
|
||||
const { servers: currentServers, setServers } = useServerStore.getState();
|
||||
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel } from './serverStore';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
export const useChatStore = create((set, get) => ({
|
||||
messages: new Map(),
|
||||
currentChannelId: null,
|
||||
@@ -12,6 +13,7 @@ export const useChatStore = create((set, get) => ({
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
||||
@@ -67,34 +69,98 @@ export const useChatStore = create((set, get) => ({
|
||||
sendMessage: async (channelId, content, attachmentIds) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
}
|
||||
else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
|
||||
// Generate optimistic message
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (currentUser) {
|
||||
const optimisticMessage = {
|
||||
id: tempId,
|
||||
channelId: isDm ? '' : channelId,
|
||||
userId: currentUser.id,
|
||||
content,
|
||||
replyToId: replyToId ?? null,
|
||||
editedAt: null,
|
||||
createdAt: Date.now(),
|
||||
user: currentUser,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
};
|
||||
if (isDm) {
|
||||
optimisticMessage.dmChannelId = channelId;
|
||||
}
|
||||
// Add optimistic message immediately
|
||||
get().addMessage(channelId, optimisticMessage);
|
||||
|
||||
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
|
||||
if (isDm) {
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === channelId
|
||||
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
|
||||
: dm
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
}
|
||||
}
|
||||
|
||||
set({ replyTo: null });
|
||||
// Message will arrive via WebSocket
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: remove the optimistic message on failure
|
||||
get().removeMessage(tempId, channelId);
|
||||
}
|
||||
},
|
||||
editMessage: async (messageId, content, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
// Optimistic: update content locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const originalMessage = messages?.find(m => m.id === messageId);
|
||||
if (originalMessage) {
|
||||
get().updateMessage({ ...originalMessage, content, editedAt: Date.now() });
|
||||
}
|
||||
else {
|
||||
await api.messages.update(messageId, { content });
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
} catch {
|
||||
// Rollback: restore the original message on failure
|
||||
if (originalMessage) {
|
||||
get().updateMessage(originalMessage);
|
||||
}
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
deleteMessage: async (messageId, channelId) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
// Optimistic: remove locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const savedMessage = messages?.find(m => m.id === messageId);
|
||||
get().removeMessage(messageId, channelId);
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
} catch {
|
||||
// Rollback: re-add the message on failure
|
||||
if (savedMessage) {
|
||||
get().addMessage(channelId, savedMessage);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
addMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
@@ -103,10 +169,34 @@ export const useChatStore = create((set, get) => ({
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
newMessages.set(channelId, [...current, message]);
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
addRealtimeMessage: (channelId, message) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id))
|
||||
return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
return { messages: newMessages, realtimeMessageEvents: newEvents };
|
||||
});
|
||||
},
|
||||
updateMessage: (message) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || message.dmChannelId;
|
||||
|
||||
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import type { MessageWithUser, Reaction, ReadState } from '@opencord/shared';
|
||||
import { api } from '../api/client';
|
||||
import { wsSend } from '../hooks/useWebSocket';
|
||||
import { isDmChannel } from './serverStore';
|
||||
import { isDmChannel, useServerStore } from './serverStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
interface TypingUser {
|
||||
userId: string;
|
||||
@@ -10,6 +11,11 @@ interface TypingUser {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface RealtimeMessageEvent {
|
||||
channelId: string;
|
||||
message: MessageWithUser;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: Map<string, MessageWithUser[]>;
|
||||
currentChannelId: string | null;
|
||||
@@ -20,6 +26,7 @@ interface ChatState {
|
||||
replyTo: MessageWithUser | null;
|
||||
readStates: Map<string, string>;
|
||||
unreadChannels: Set<string>;
|
||||
realtimeMessageEvents: RealtimeMessageEvent[];
|
||||
setCurrentChannel: (channelId: string | null) => void;
|
||||
setReplyTo: (message: MessageWithUser | null) => void;
|
||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||
@@ -29,6 +36,7 @@ interface ChatState {
|
||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||
addRealtimeMessage: (channelId: string, message: MessageWithUser) => void;
|
||||
updateMessage: (message: MessageWithUser) => void;
|
||||
removeMessage: (messageId: string, channelId: string) => void;
|
||||
addReaction: (messageId: string, emoji: string) => void;
|
||||
@@ -55,6 +63,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
replyTo: null,
|
||||
readStates: new Map(),
|
||||
unreadChannels: new Set(),
|
||||
realtimeMessageEvents: [],
|
||||
|
||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
||||
setReplyTo: (message) => set({ replyTo: message }),
|
||||
@@ -113,35 +122,103 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
||||
const replyToId = get().replyTo?.id;
|
||||
const isDm = isDmChannel(channelId);
|
||||
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
|
||||
// Generate optimistic message
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
if (currentUser) {
|
||||
const optimisticMessage: MessageWithUser = {
|
||||
id: tempId,
|
||||
channelId: isDm ? '' : channelId,
|
||||
userId: currentUser.id,
|
||||
content,
|
||||
replyToId: replyToId ?? null,
|
||||
editedAt: null,
|
||||
createdAt: Date.now(),
|
||||
user: currentUser,
|
||||
attachments: [],
|
||||
reactions: [],
|
||||
};
|
||||
if (isDm) {
|
||||
(optimisticMessage as any).dmChannelId = channelId;
|
||||
}
|
||||
// Add optimistic message immediately
|
||||
get().addMessage(channelId, optimisticMessage);
|
||||
|
||||
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
|
||||
if (isDm) {
|
||||
const { dmChannels, setDmChannels } = useServerStore.getState();
|
||||
const updatedDms = dmChannels.map(dm =>
|
||||
dm.id === channelId
|
||||
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
|
||||
: dm
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
set({ replyTo: null });
|
||||
// Message will arrive via WebSocket
|
||||
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.sendMessage(channelId, { content });
|
||||
} else {
|
||||
await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||
}
|
||||
// Real message will arrive via WebSocket and replace the temp one
|
||||
} catch {
|
||||
// Rollback: remove the optimistic message on failure
|
||||
get().removeMessage(tempId, channelId);
|
||||
}
|
||||
},
|
||||
|
||||
editMessage: async (messageId: string, content: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
// Optimistic: update content locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const originalMessage = messages?.find(m => m.id === messageId);
|
||||
if (originalMessage) {
|
||||
get().updateMessage({ ...originalMessage, content, editedAt: Date.now() });
|
||||
}
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
// Real update will arrive via WebSocket
|
||||
} catch {
|
||||
// Rollback: restore the original message on failure
|
||||
if (originalMessage) {
|
||||
get().updateMessage(originalMessage);
|
||||
}
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
|
||||
deleteMessage: async (messageId: string, channelId: string) => {
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
// Optimistic: remove locally first
|
||||
const messages = get().messages.get(channelId);
|
||||
const savedMessage = messages?.find(m => m.id === messageId);
|
||||
get().removeMessage(messageId, channelId);
|
||||
try {
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Real deletion will arrive via WebSocket (already removed locally)
|
||||
} catch {
|
||||
// Rollback: re-add the message on failure
|
||||
if (savedMessage) {
|
||||
get().addMessage(channelId, savedMessage);
|
||||
}
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
|
||||
addMessage: (channelId: string, message: MessageWithUser) => {
|
||||
@@ -150,11 +227,35 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id)) return state;
|
||||
newMessages.set(channelId, [...current, message]);
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
|
||||
addRealtimeMessage: (channelId: string, message: MessageWithUser) => {
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(channelId) ?? [];
|
||||
// Avoid duplicates
|
||||
if (current.find(m => m.id === message.id)) return state;
|
||||
// Remove any optimistic temp message from same user with same content
|
||||
const filtered = current.filter(m => {
|
||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||
return m.content !== message.content;
|
||||
});
|
||||
newMessages.set(channelId, [...filtered, message]);
|
||||
// Append to realtimeMessageEvents (capped at 50)
|
||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||
return { messages: newMessages, realtimeMessageEvents: newEvents };
|
||||
});
|
||||
},
|
||||
|
||||
updateMessage: (message: MessageWithUser) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
||||
|
||||
@@ -19,6 +19,15 @@ export const useServerStore = create((set, get) => ({
|
||||
addDmChannel: (channel) => set((state) => ({
|
||||
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
|
||||
})),
|
||||
removeDmChannel: (id) => set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
})),
|
||||
closeDm: async (id) => {
|
||||
await api.dm.close(id);
|
||||
set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
}));
|
||||
},
|
||||
loadServers: async () => {
|
||||
try {
|
||||
const servers = await api.servers.list();
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ServerState {
|
||||
setRoles: (roles: Role[]) => void;
|
||||
setDmChannels: (channels: DmChannel[]) => void;
|
||||
addDmChannel: (channel: DmChannel) => void;
|
||||
removeDmChannel: (id: string) => void;
|
||||
closeDm: (id: string) => Promise<void>;
|
||||
loadServers: () => Promise<void>;
|
||||
loadServerDetail: (serverId: string) => Promise<void>;
|
||||
loadDmChannels: () => Promise<void>;
|
||||
@@ -60,6 +62,17 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
||||
dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)]
|
||||
})),
|
||||
|
||||
removeDmChannel: (id) => set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
})),
|
||||
|
||||
closeDm: async (id) => {
|
||||
await api.dm.close(id);
|
||||
set((state) => ({
|
||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||
}));
|
||||
},
|
||||
|
||||
loadServers: async () => {
|
||||
try {
|
||||
const servers = await api.servers.list();
|
||||
|
||||
@@ -102,4 +102,18 @@ export const useSocialStore = create((set, get) => ({
|
||||
requests: state.requests.filter(r => r.id !== requestId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler when the other user removes us as a friend
|
||||
removeFriendLocally: (userId) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== userId),
|
||||
}));
|
||||
},
|
||||
// Called from WS handler on presence_update to keep friend status live
|
||||
updateFriendPresence: (userId, status) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.map(f =>
|
||||
f.id === userId ? { ...f, status } : f
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SocialState {
|
||||
searchUsers: (query: string) => Promise<User[]>;
|
||||
addIncomingRequest: (request: FriendRequest) => void;
|
||||
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
||||
updateFriendPresence: (userId: string, status: string) => void;
|
||||
removeFriendLocally: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
@@ -121,4 +123,20 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
||||
requests: state.requests.filter(r => r.id !== requestId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Called from WS handler when the other user removes us as a friend
|
||||
removeFriendLocally: (userId: string) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.filter(f => f.id !== userId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Called from WS handler on presence_update to keep friend status live
|
||||
updateFriendPresence: (userId: string, status: string) => {
|
||||
set((state) => ({
|
||||
friends: state.friends.map(f =>
|
||||
f.id === userId ? { ...f, status: status as Friend['status'] } : f
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,40 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
export const useUIStore = create((set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile)
|
||||
return;
|
||||
set({ isMobile, sidebarOpen: !isMobile, memberListOpen: !isMobile });
|
||||
},
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
export const useUIStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile)
|
||||
return;
|
||||
if (isMobile) {
|
||||
set({ isMobile, sidebarOpen: false, memberListOpen: false });
|
||||
}
|
||||
else {
|
||||
// On desktop transition, restore sidebarOpen but leave memberListOpen
|
||||
// at its persisted/toggled value — don't override user preference
|
||||
set({ isMobile, sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { User } from '@opencord/shared';
|
||||
|
||||
type ModalType =
|
||||
@@ -43,48 +44,65 @@ interface UIState {
|
||||
setPipCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sidebarOpen: true,
|
||||
memberListOpen: true,
|
||||
activeModal: null,
|
||||
modalData: {},
|
||||
isMobile: false,
|
||||
showDms: false,
|
||||
imagePreviewUrl: null,
|
||||
userProfilePopout: {
|
||||
user: null,
|
||||
position: null,
|
||||
},
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
|
||||
closeModal: () => set({ activeModal: null, modalData: {} }),
|
||||
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile) return;
|
||||
set({ isMobile, sidebarOpen: !isMobile, memberListOpen: !isMobile });
|
||||
},
|
||||
setIsMobile: (isMobile) => {
|
||||
const prev = get().isMobile;
|
||||
if (prev === isMobile) return;
|
||||
if (isMobile) {
|
||||
set({ isMobile, sidebarOpen: false, memberListOpen: false });
|
||||
} else {
|
||||
// On desktop transition, restore sidebarOpen but leave memberListOpen
|
||||
// at its persisted/toggled value — don't override user preference
|
||||
set({ isMobile, sidebarOpen: true });
|
||||
}
|
||||
},
|
||||
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
setShowDms: (show) => set({ showDms: show }),
|
||||
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
openUserProfile: (user, position) => set({
|
||||
userProfilePopout: { user, position }
|
||||
}),
|
||||
closeUserProfile: () => set({
|
||||
userProfilePopout: { user: null, position: null }
|
||||
}),
|
||||
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}));
|
||||
voiceChatOpen: false,
|
||||
voiceFullscreen: false,
|
||||
pipCollapsed: false,
|
||||
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
|
||||
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
|
||||
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
|
||||
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
|
||||
}),
|
||||
{
|
||||
name: 'opencord-ui-settings',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
memberListOpen: state.memberListOpen,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user