From 6b8fd44a3aaef340c399c37e07bd80f96fa42926 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:15:11 +0100 Subject: [PATCH] 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 --- packages/server/src/routes/channels.ts | 30 +++- packages/server/src/routes/dm.ts | 120 ++++++++++++++- packages/server/src/routes/servers.ts | 16 +- packages/server/src/routes/social.ts | 6 + packages/shared/src/types.ts | 7 + packages/web/src/App.js | 2 +- packages/web/src/App.tsx | 8 - packages/web/src/api/client.js | 1 + packages/web/src/api/client.ts | 1 + .../web/src/components/chat/FriendsPage.js | 6 +- .../web/src/components/chat/FriendsPage.tsx | 16 +- .../src/components/layout/ActivityPanel.js | 39 +++++ .../src/components/layout/ActivityPanel.tsx | 104 +++++++++++++ .../web/src/components/layout/AppLayout.js | 5 +- .../web/src/components/layout/AppLayout.tsx | 22 +-- .../src/components/layout/ChannelSidebar.js | 2 +- .../src/components/layout/ChannelSidebar.tsx | 10 +- .../web/src/components/layout/MainContent.js | 13 +- .../web/src/components/layout/MainContent.tsx | 38 ++--- .../layout/MemberListToggleButton.js | 7 + .../layout/MemberListToggleButton.tsx | 21 +++ .../web/src/components/layout/RightPanel.js | 13 ++ .../web/src/components/layout/RightPanel.tsx | 16 ++ .../src/components/voice/SoundController.js | 16 +- .../src/components/voice/SoundController.tsx | 17 +-- packages/web/src/hooks/useWebSocket.js | 101 ++++++++++--- packages/web/src/hooks/useWebSocket.ts | 115 ++++++++++++--- packages/web/src/stores/chatStore.js | 128 +++++++++++++--- packages/web/src/stores/chatStore.ts | 139 +++++++++++++++--- packages/web/src/stores/serverStore.js | 9 ++ packages/web/src/stores/serverStore.ts | 13 ++ packages/web/src/stores/socialStore.js | 14 ++ packages/web/src/stores/socialStore.ts | 18 +++ packages/web/src/stores/uiStore.js | 97 +++++++----- packages/web/src/stores/uiStore.ts | 94 +++++++----- 35 files changed, 1002 insertions(+), 262 deletions(-) create mode 100644 packages/web/src/components/layout/ActivityPanel.js create mode 100644 packages/web/src/components/layout/ActivityPanel.tsx create mode 100644 packages/web/src/components/layout/MemberListToggleButton.js create mode 100644 packages/web/src/components/layout/MemberListToggleButton.tsx create mode 100644 packages/web/src/components/layout/RightPanel.js create mode 100644 packages/web/src/components/layout/RightPanel.tsx diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 7e653d53..e440ae97 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -4,6 +4,7 @@ import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; import { isMember, isAdmin, getChannelServerId } from '../utils/permissions.js'; +import { connectionManager } from '../ws/handler.js'; import type { CreateChannelRequest, UpdateChannelRequest, @@ -106,7 +107,16 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 }); } - return reply.code(201).send(rowToChannel(channel)); + const channelData = rowToChannel(channel); + + // Broadcast channel_created to all server members + connectionManager.sendToServer(id, { + type: 'channel_created', + channel: channelData, + serverId: id, + }); + + return reply.code(201).send(channelData); }); // PATCH /api/channels/:id - Update a channel (admin+) @@ -159,7 +169,16 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 }); } - return reply.code(200).send(rowToChannel(updated)); + const channelData = rowToChannel(updated); + + // Broadcast channel_updated to all server members + connectionManager.sendToServer(serverId, { + type: 'channel_updated', + channel: channelData, + serverId, + }); + + return reply.code(200).send(channelData); }); // DELETE /api/channels/:id - Delete a channel (admin+) @@ -183,6 +202,13 @@ export async function channelRoutes(app: FastifyInstance): Promise { db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run(); db.delete(schema.channels).where(eq(schema.channels.id, id)).run(); + // Broadcast channel_deleted to all server members + connectionManager.sendToServer(serverId, { + type: 'channel_deleted', + channelId: id, + serverId, + }); + return reply.code(200).send({ success: true }); }); } diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index cf180443..f665756d 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -115,7 +115,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - // Check if DM channel already exists between these two users + // Check if DM channel already exists between these two users (both are members) const myDms = db.select() .from(schema.dmMembers) .where(eq(schema.dmMembers.userId, request.userId)) @@ -131,7 +131,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { .get(); if (otherMember) { - // DM channel already exists + // DM channel already exists, both users are members const dmChannel = db.select() .from(schema.dmChannels) .where(eq(schema.dmChannels.id, myDm.dmChannelId)) @@ -173,6 +173,79 @@ export async function dmRoutes(app: FastifyInstance): Promise { } } + // Check if the other user has a DM channel with us that we left (closed) + // If so, re-add ourselves to it instead of creating a new one + const theirDms = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.userId, userId)) + .all(); + + for (const theirDm of theirDms) { + // Check if any messages exist between us on this channel (indicates a previous DM) + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, theirDm.dmChannelId)) + .get(); + + if (!dmChannel) continue; + + // Check total members — a DM channel should only have the other user if we left + const allMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, theirDm.dmChannelId)) + .all(); + + // If it's a 1-member channel (just them) or we see past messages from us, re-join + const onlyOtherUser = allMembers.length === 1 && allMembers[0]!.userId === userId; + if (onlyOtherUser) { + // Check if there are any messages from us in this channel (confirms it was our DM) + const ourOldMessages = db.select() + .from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId), + eq(schema.dmMessages.userId, request.userId), + )) + .limit(1) + .all(); + + if (ourOldMessages.length > 0) { + // Re-add ourselves to this existing DM channel + db.insert(schema.dmMembers).values({ + dmChannelId: theirDm.dmChannelId, + userId: request.userId, + }).run(); + + const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + const members = [currentUserRow, targetUser] + .filter((u): u is NonNullable => u !== undefined) + .map(sanitizeUser); + + const lastMsgRows = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, theirDm.dmChannelId)) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(1) + .all(); + const lastMsg = lastMsgRows[0] ?? null; + + const result: DmChannel = { + id: dmChannel.id, + createdAt: dmChannel.createdAt, + members, + lastMessage: lastMsg ? { + id: lastMsg.id, + dmChannelId: lastMsg.dmChannelId, + userId: lastMsg.userId, + content: lastMsg.content, + createdAt: lastMsg.createdAt, + } : null, + }; + + return reply.code(200).send(result); + } + } + } + // Create new DM channel const dmChannelId = generateSnowflake(); const now = Date.now(); @@ -204,9 +277,52 @@ export async function dmRoutes(app: FastifyInstance): Promise { lastMessage: null, }; + // Broadcast dm_channel_created to the other user so their sidebar updates + connectionManager.sendToUser(userId, { + type: 'dm_channel_created', + dmChannel: result, + }); + return reply.code(201).send(result); }); + // DELETE /api/dm/:id - Close (hide) a DM channel for the requesting user + app.delete<{ Params: { id: string } }>('/api/dm/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + // Verify the user is a member of this DM channel + const membership = db.select() + .from(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, id), + eq(schema.dmMembers.userId, request.userId), + )) + .get(); + + if (!membership) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + // Soft close: remove the user's membership row (Discord-style hide) + db.delete(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, id), + eq(schema.dmMembers.userId, request.userId), + )) + .run(); + + // Broadcast dm_channel_closed to self for multi-tab sync + connectionManager.sendToUser(request.userId, { + type: 'dm_channel_closed', + dmChannelId: id, + }); + + return reply.code(200).send({ success: true }); + }); + // GET /api/dm/:id/messages - Get DM messages with pagination app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', { preHandler: authenticate, diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index b2d57e8d..54b43aa8 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -275,7 +275,15 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 }); } - return reply.code(200).send(rowToServer(updated)); + const serverData = rowToServer(updated); + + // Broadcast server_updated to all server members + connectionManager.sendToServer(id, { + type: 'server_updated', + server: serverData, + }); + + return reply.code(200).send(serverData); }); // DELETE /api/servers/:id - Delete server (owner only) @@ -363,6 +371,9 @@ export async function serverRoutes(app: FastifyInstance): Promise { joinedAt: now, }).run(); + // Register the user in connectionManager so they receive WS broadcasts for this server + connectionManager.addUserServer(request.userId, id); + return reply.code(200).send(rowToServer(server)); }); @@ -395,6 +406,9 @@ export async function serverRoutes(app: FastifyInstance): Promise { joinedAt: now, }).run(); + // Register the user in connectionManager so they receive WS broadcasts for this server + connectionManager.addUserServer(request.userId, server.id); + return reply.code(200).send(rowToServer(server)); }); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 01b5fb09..93ff49d9 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -279,6 +279,12 @@ export async function socialRoutes(app: FastifyInstance): Promise { and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId)) )).run(); + // Broadcast friend_removed to the other user so their friends list updates + connectionManager.sendToUser(id, { + type: 'friend_removed', + userId: request.userId, + }); + return reply.code(200).send({ success: true }); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index aaafe2dc..e071f723 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -213,6 +213,13 @@ export type ServerEvent = | { type: 'dm_call_rejected'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string } | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean } + | { type: 'dm_channel_created'; dmChannel: DmChannel } + | { type: 'dm_channel_closed'; dmChannelId: string } + | { type: 'friend_removed'; userId: string } + | { type: 'channel_created'; channel: Channel; serverId: string } + | { type: 'channel_updated'; channel: Channel; serverId: string } + | { type: 'channel_deleted'; channelId: string; serverId: string } + | { type: 'server_updated'; server: Server } | { type: 'error'; message: string }; // ─── API Request/Response Types ───────────────────────────────────────────── diff --git a/packages/web/src/App.js b/packages/web/src/App.js index 8b4785a8..b7e3302f 100644 --- a/packages/web/src/App.js +++ b/packages/web/src/App.js @@ -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 }) })] })); } diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 90952185..a37a0146 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -44,14 +44,6 @@ export function App() { } /> - - - - } - /> 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) diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index e49f932b..c2e5df50 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -149,6 +149,7 @@ export const api = { dm: { list: () => request('GET', '/dm'), create: (data: CreateDmRequest) => request('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); diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js index 8bce877f..8a151d26 100644 --- a/packages/web/src/components/chat/FriendsPage.js +++ b/packages/web/src/components/chat/FriendsPage.js @@ -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 })); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 1098b095..55d7e1bf 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -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() {
- +
diff --git a/packages/web/src/components/layout/ActivityPanel.js b/packages/web/src/components/layout/ActivityPanel.js new file mode 100644 index 00000000..549359c8 --- /dev/null +++ b/packages/web/src/components/layout/ActivityPanel.js @@ -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))] }))] }))] }) })); +} diff --git a/packages/web/src/components/layout/ActivityPanel.tsx b/packages/web/src/components/layout/ActivityPanel.tsx new file mode 100644 index 00000000..c7c03093 --- /dev/null +++ b/packages/web/src/components/layout/ActivityPanel.tsx @@ -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) => ( +
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" + > + +
+
+ {friend.displayName ?? friend.username} +
+ {!isOffline && friend.customStatus && ( +
{friend.customStatus}
+ )} +
+
+ ); + + return ( +
+
+

Active Now

+ + {onlineFriends.length === 0 && offlineFriends.length === 0 ? ( +
+
It's quiet for now...
+
+ When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here! +
+
+ ) : ( + <> + {onlineFriends.length > 0 && ( +
+

+ ONLINE — {onlineFriends.length} +

+ {onlineFriends.map(f => renderFriend(f))} +
+ )} + {offlineFriends.length > 0 && ( +
+

+ OFFLINE — {offlineFriends.length} +

+ {offlineFriends.map(f => renderFriend(f, true))} +
+ )} + + )} +
+
+ ); +} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index d9d7b614..119cbe76 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -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 })] }))] })); } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 8e71c986..f0708c10 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -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 */}
- {serverId === '@me' ? ( - memberListOpen ? ( -
-
-

Active Now

-
-
It's quiet for now...
-
- When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here! -
-
-
-
- ) : null - ) : ( - - )} +
{/* Modals */} diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index e4d061da..29825ecf 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -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) => { diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 3a36dc1a..6007642a 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -208,7 +208,15 @@ export function ChannelSidebar() { )} - +
- +