diff --git a/server/src/index.ts b/server/src/index.ts index 1ea754d..404bc72 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -38,7 +38,8 @@ interface JoinRequest { interface ConnectedUser { oderId: string; ws: WebSocket; - serverId?: string; + serverIds: Set; // all servers the user is a member of + viewedServerId?: string; // currently viewed/active server displayName?: string; } @@ -361,7 +362,7 @@ const wss = new WebSocketServer({ server }); wss.on('connection', (ws: WebSocket) => { const connectionId = uuidv4(); - connectedUsers.set(connectionId, { oderId: connectionId, ws }); + connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); ws.on('message', (data) => { try { @@ -374,13 +375,16 @@ wss.on('connection', (ws: WebSocket) => { ws.on('close', () => { const user = connectedUsers.get(connectionId); - if (user?.serverId) { - // Notify others in the room - use user.oderId (the actual user ID), not connectionId - broadcastToServer(user.serverId, { - type: 'user_left', - oderId: user.oderId, - displayName: user.displayName, - }, user.oderId); + if (user) { + // Notify all servers the user was a member of + user.serverIds.forEach((sid) => { + broadcastToServer(sid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName, + serverId: sid, + }, user.oderId); + }); } connectedUsers.delete(connectionId); }); @@ -403,44 +407,76 @@ function handleWebSocketMessage(connectionId: string, message: any): void { console.log(`User identified: ${user.displayName} (${user.oderId})`); break; - case 'join_server': - user.serverId = message.serverId; + case 'join_server': { + const sid = message.serverId; + const isNew = !user.serverIds.has(sid); + user.serverIds.add(sid); + user.viewedServerId = sid; connectedUsers.set(connectionId, user); - console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${message.serverId}`); + console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); - // Get list of current users in server (exclude this user by oderId) - // Only include users that have been identified (have displayName) + // Always send the current user list for this server const usersInServer = Array.from(connectedUsers.values()) - .filter(u => u.serverId === message.serverId && u.oderId !== user.oderId && u.displayName) + .filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName) .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer); user.ws.send(JSON.stringify({ type: 'server_users', + serverId: sid, users: usersInServer, })); - // Notify others (exclude by oderId, not connectionId) - broadcastToServer(message.serverId, { - type: 'user_joined', - oderId: user.oderId, - displayName: user.displayName || 'Anonymous', - }, user.oderId); - break; - - case 'leave_server': - const oldServerId = user.serverId; - user.serverId = undefined; - connectedUsers.set(connectionId, user); - - if (oldServerId) { - broadcastToServer(oldServerId, { - type: 'user_left', + // Only broadcast user_joined if this is a brand-new join (not a re-view) + if (isNew) { + broadcastToServer(sid, { + type: 'user_joined', oderId: user.oderId, displayName: user.displayName || 'Anonymous', + serverId: sid, }, user.oderId); } break; + } + + case 'view_server': { + // Just switch the viewed server without joining/leaving + const viewSid = message.serverId; + user.viewedServerId = viewSid; + connectedUsers.set(connectionId, user); + console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); + + // Send current user list for the viewed server + const viewUsers = Array.from(connectedUsers.values()) + .filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName) + .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); + + user.ws.send(JSON.stringify({ + type: 'server_users', + serverId: viewSid, + users: viewUsers, + })); + break; + } + + case 'leave_server': { + const leaveSid = message.serverId || user.viewedServerId; + if (leaveSid) { + user.serverIds.delete(leaveSid); + if (user.viewedServerId === leaveSid) { + user.viewedServerId = undefined; + } + connectedUsers.set(connectionId, user); + + broadcastToServer(leaveSid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName || 'Anonymous', + serverId: leaveSid, + }, user.oderId); + } + break; + } case 'offer': case 'answer': @@ -460,11 +496,13 @@ function handleWebSocketMessage(connectionId: string, message: any): void { } break; - case 'chat_message': + case 'chat_message': { // Broadcast chat message to all users in the server - if (user.serverId) { - broadcastToServer(user.serverId, { + const chatSid = message.serverId || user.viewedServerId; + if (chatSid && user.serverIds.has(chatSid)) { + broadcastToServer(chatSid, { type: 'chat_message', + serverId: chatSid, message: message.message, senderId: user.oderId, senderName: user.displayName, @@ -472,17 +510,21 @@ function handleWebSocketMessage(connectionId: string, message: any): void { }); } break; + } - case 'typing': + case 'typing': { // Broadcast typing indicator - if (user.serverId) { - broadcastToServer(user.serverId, { + const typingSid = message.serverId || user.viewedServerId; + if (typingSid && user.serverIds.has(typingSid)) { + broadcastToServer(typingSid, { type: 'user_typing', + serverId: typingSid, oderId: user.oderId, displayName: user.displayName, }, user.oderId); } break; + } default: console.log('Unknown message type:', message.type); @@ -492,7 +534,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void { function broadcastToServer(serverId: string, message: any, excludeOderId?: string): void { console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); connectedUsers.forEach((user) => { - if (user.serverId === serverId && user.oderId !== excludeOderId) { + if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { console.log(` -> Sending to ${user.displayName} (${user.oderId})`); user.ws.send(JSON.stringify(message)); } diff --git a/src/app/core/services/voice-session.service.ts b/src/app/core/services/voice-session.service.ts index c62fb4a..1c51ea1 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/core/services/voice-session.service.ts @@ -85,14 +85,19 @@ export class VoiceSessionService { navigateToVoiceServer(): void { const session = this._voiceSession(); if (session) { - // Dispatch joinRoom action to update the store state - this.store.dispatch(RoomsActions.joinRoom({ - roomId: session.serverId, - serverInfo: { + // Use viewServer to switch view without leaving current server + this.store.dispatch(RoomsActions.viewServer({ + room: { + id: session.serverId, name: session.serverName, description: session.serverDescription, - hostName: 'Unknown', - }, + hostId: '', + isPrivate: false, + createdAt: 0, + userCount: 0, + maxUsers: 50, + icon: session.serverIcon, + } as any, })); this._isViewingVoiceServer.set(true); } diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 06f2983..7158efe 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -43,6 +43,8 @@ export class WebRTCService { private currentServerId: string | null = null; private lastIdentify: { oderId: string; displayName: string } | null = null; private lastJoin: { serverId: string; userId: string } | null = null; + // Track all servers the user has joined (for multi-server membership) + private joinedServerIds = new Set(); // Signals for reactive state private readonly _peerId = signal(uuidv4()); @@ -190,7 +192,22 @@ export class WebRTCService { displayName: this.lastIdentify.displayName, }); } - if (this.lastJoin) { + // Rejoin ALL servers the user was a member of (multi-server) + if (this.joinedServerIds.size > 0) { + this.joinedServerIds.forEach((sid) => { + this.sendRawMessage({ + type: 'join_server', + serverId: sid, + }); + }); + // Re-view the last viewed server + if (this.lastJoin) { + this.sendRawMessage({ + type: 'view_server', + serverId: this.lastJoin.serverId, + }); + } + } else if (this.lastJoin) { this.sendRawMessage({ type: 'join_server', serverId: this.lastJoin.serverId, @@ -619,8 +636,12 @@ export class WebRTCService { break; case 'user_left': - this.log('User left', { displayName: message.displayName, oderId: message.oderId }); - this.removePeer(message.oderId); + this.log('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId }); + // With multi-server membership, only remove peer if they are leaving the + // currently viewed server AND we don't share any other server with them. + // For now, don't remove peers on server-specific leave – + // the signaling server will send a proper disconnect when the WS closes. + // The store effect handles removing the user from the user list. break; case 'offer': @@ -1162,9 +1183,10 @@ export class WebRTCService { }); } - // Join a room + // Join a room (first-time join – adds to multi-server membership) joinRoom(roomId: string, userId: string): void { this.lastJoin = { serverId: roomId, userId }; + this.joinedServerIds.add(roomId); this.sendRawMessage({ type: 'join_server', serverId: roomId, @@ -1172,23 +1194,30 @@ export class WebRTCService { } /** - * Switch to a different server without disconnecting voice or peers. - * This is used when navigating between servers while already connected. - * If voice is connected, the user stays in voice in the original server. + * Switch the viewed server without affecting multi-server membership. + * If the server hasn't been joined yet, performs a full join. + * If already joined, just sends view_server to refresh user list. */ switchServer(serverId: string, userId: string): void { // Update the last join info for reconnection purposes this.lastJoin = { serverId, userId }; - // Send join_server to switch rooms on the signaling server - // This tells the server we're now in a different room, but doesn't - // affect our peer connections or voice state - this.sendRawMessage({ - type: 'join_server', - serverId: serverId, - }); - - this.log('Switched server', { serverId, userId, voiceConnected: this._isVoiceConnected() }); + if (this.joinedServerIds.has(serverId)) { + // Already a member – just switch the view + this.sendRawMessage({ + type: 'view_server', + serverId: serverId, + }); + this.log('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() }); + } else { + // Not yet joined – do a full join + this.joinedServerIds.add(serverId); + this.sendRawMessage({ + type: 'join_server', + serverId: serverId, + }); + this.log('Joined new server via switch', { serverId, userId, voiceConnected: this._isVoiceConnected() }); + } } private scheduleReconnect(): void { @@ -1329,12 +1358,37 @@ export class WebRTCService { } } - // Leave room - leaveRoom(): void { - this.sendRawMessage({ - type: 'leave_server', - }); + // Leave a specific server (or all if no serverId given) + leaveRoom(serverId?: string): void { + if (serverId) { + // Leave a specific server + this.joinedServerIds.delete(serverId); + this.sendRawMessage({ + type: 'leave_server', + serverId: serverId, + }); + this.log('Left server', { serverId }); + // If no servers left, do full cleanup + if (this.joinedServerIds.size === 0) { + this.fullCleanup(); + } + return; + } + + // Leave ALL servers and clean up + this.joinedServerIds.forEach((sid) => { + this.sendRawMessage({ + type: 'leave_server', + serverId: sid, + }); + }); + this.joinedServerIds.clear(); + this.fullCleanup(); + } + + // Internal full cleanup – close peers, stop media + private fullCleanup(): void { // Clear all P2P reconnect timers this.clearAllP2PReconnectTimers(); @@ -1353,9 +1407,19 @@ export class WebRTCService { this.stopScreenShare(); } + /** Check whether we are already a member of the given server */ + hasJoinedServer(serverId: string): boolean { + return this.joinedServerIds.has(serverId); + } + + /** Get all server IDs we are currently a member of */ + getJoinedServerIds(): ReadonlySet { + return this.joinedServerIds; + } + // Disconnect from signaling server disconnect(): void { - this.leaveRoom(); + this.leaveRoom(); // leaves all servers this.stopVoiceHeartbeat(); if (this.signalingSocket) { diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts index 371a497..7e353a9 100644 --- a/src/app/features/servers/servers-rail.component.ts +++ b/src/app/features/servers/servers-rail.component.ts @@ -8,6 +8,7 @@ import { lucidePlus } from '@ng-icons/lucide'; import { Room } from '../../core/models'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { VoiceSessionService } from '../../core/services/voice-session.service'; +import { WebRTCService } from '../../core/services/webrtc.service'; import * as RoomsActions from '../../store/rooms/rooms.actions'; @Component({ @@ -21,6 +22,7 @@ export class ServersRailComponent { private store = inject(Store); private router = inject(Router); private voiceSession = inject(VoiceSessionService); + private webrtc = inject(WebRTCService); savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); @@ -52,8 +54,6 @@ export class ServersRailComponent { joinSavedRoom(room: Room): void { // Require auth: if no current user, go to login - const current = this.currentRoom(); - // currentRoom presence does not indicate auth; check localStorage for currentUserId const currentUserId = localStorage.getItem('metoyou_currentUserId'); if (!currentUserId) { this.router.navigate(['/login']); @@ -64,21 +64,28 @@ export class ServersRailComponent { const voiceServerId = this.voiceSession.getVoiceServerId(); if (voiceServerId && voiceServerId !== room.id) { // User is switching to a different server while connected to voice - // Update voice session to show floating controls + // Update voice session to show floating controls (voice stays connected) this.voiceSession.setViewingVoiceServer(false); } else if (voiceServerId === room.id) { // Navigating back to the voice-connected server this.voiceSession.setViewingVoiceServer(true); } - this.store.dispatch(RoomsActions.joinRoom({ - roomId: room.id, - serverInfo: { - name: room.name, - description: room.description, - hostName: room.hostId || 'Unknown', - }, - })); + // If we've already joined this server, just switch the view + // (no user_joined broadcast, no leave from other servers) + if (this.webrtc.hasJoinedServer(room.id)) { + this.store.dispatch(RoomsActions.viewServer({ room })); + } else { + // First time joining this server + this.store.dispatch(RoomsActions.joinRoom({ + roomId: room.id, + serverInfo: { + name: room.name, + description: room.description, + hostName: room.hostId || 'Unknown', + }, + })); + } } openContextMenu(evt: MouseEvent, room: Room): void { diff --git a/src/app/store/rooms/rooms.actions.ts b/src/app/store/rooms/rooms.actions.ts index 23a5166..c0f7092 100644 --- a/src/app/store/rooms/rooms.actions.ts +++ b/src/app/store/rooms/rooms.actions.ts @@ -67,6 +67,17 @@ export const leaveRoom = createAction('[Rooms] Leave Room'); export const leaveRoomSuccess = createAction('[Rooms] Leave Room Success'); +// View server (switch view without disconnecting) +export const viewServer = createAction( + '[Rooms] View Server', + props<{ room: Room }>() +); + +export const viewServerSuccess = createAction( + '[Rooms] View Server Success', + props<{ room: Room }>() +); + // Delete room export const deleteRoom = createAction( '[Rooms] Delete Room', diff --git a/src/app/store/rooms/rooms.effects.ts b/src/app/store/rooms/rooms.effects.ts index bbd0bad..23e8804 100644 --- a/src/app/store/rooms/rooms.effects.ts +++ b/src/app/store/rooms/rooms.effects.ts @@ -196,9 +196,8 @@ export class RoomsEffects { // Check if already connected to signaling server if (this.webrtc.isConnected()) { - // Already connected - just switch to the new server without reconnecting WebSocket - // This preserves voice connections and peer state - console.log('Already connected to signaling, switching to room:', room.id); + // Already connected - join the new server (additive, multi-server) + console.log('Already connected to signaling, joining room:', room.id); this.webrtc.setCurrentServer(room.id); this.webrtc.switchServer(room.id, oderId); } else { @@ -223,6 +222,37 @@ export class RoomsEffects { { dispatch: false } ); + // View server – switch view to an already-joined server without leaving others + viewServer$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.viewServer), + withLatestFrom(this.store.select(selectCurrentUser)), + mergeMap(([{ room }, user]) => { + const oderId = user?.oderId || this.webrtc.peerId(); + + if (this.webrtc.isConnected()) { + this.webrtc.setCurrentServer(room.id); + this.webrtc.switchServer(room.id, oderId); + } + + this.router.navigate(['/room', room.id]); + return of(RoomsActions.viewServerSuccess({ room })); + }) + ) + ); + + // When viewing a different server, reload messages and users for that server + onViewServerSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(RoomsActions.viewServerSuccess), + mergeMap(({ room }) => [ + UsersActions.clearUsers(), + MessagesActions.loadMessages({ roomId: room.id }), + UsersActions.loadBans(), + ]) + ) + ); + // Leave room leaveRoom$ = createEffect(() => this.actions$.pipe( @@ -280,10 +310,8 @@ export class RoomsEffects { // Delete from local DB this.db.deleteRoom(roomId); - // If currently in this room, disconnect - if (currentRoom?.id === roomId) { - this.webrtc.disconnectAll(); - } + // Leave this specific server (doesn't affect other servers) + this.webrtc.leaveRoom(roomId); return of(RoomsActions.forgetRoomSuccess({ roomId })); }) @@ -451,12 +479,23 @@ export class RoomsEffects { // Listen to WebRTC signaling messages for user presence signalingMessages$ = createEffect(() => this.webrtc.onSignalingMessage.pipe( - withLatestFrom(this.store.select(selectCurrentUser)), - mergeMap(([message, currentUser]: [any, any]) => { + withLatestFrom( + this.store.select(selectCurrentUser), + this.store.select(selectCurrentRoom), + ), + mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => { const actions: any[] = []; const myId = currentUser?.oderId || currentUser?.id; + const viewedServerId = currentRoom?.id; if (message.type === 'server_users' && message.users) { + // Only populate for the currently viewed server + const msgServerId = message.serverId; + if (msgServerId && viewedServerId && msgServerId !== viewedServerId) { + return [{ type: 'NO_OP' }]; + } + // Clear existing users first, then add the new set + actions.push(UsersActions.clearUsers()); // Add all existing users to the store (excluding ourselves) message.users.forEach((user: { oderId: string; displayName: string }) => { // Don't add ourselves to the list @@ -478,6 +517,11 @@ export class RoomsEffects { } }); } else if (message.type === 'user_joined') { + // Only add to user list if this event is for the currently viewed server + const msgServerId = message.serverId; + if (msgServerId && viewedServerId && msgServerId !== viewedServerId) { + return [{ type: 'NO_OP' }]; + } // Don't add ourselves to the list if (message.oderId !== myId) { actions.push( @@ -496,6 +540,11 @@ export class RoomsEffects { ); } } else if (message.type === 'user_left') { + // Only remove from user list if this event is for the currently viewed server + const msgServerId = message.serverId; + if (msgServerId && viewedServerId && msgServerId !== viewedServerId) { + return [{ type: 'NO_OP' }]; + } actions.push(UsersActions.userLeft({ userId: message.oderId })); } diff --git a/src/app/store/rooms/rooms.reducer.ts b/src/app/store/rooms/rooms.reducer.ts index f699ac9..f1af5e1 100644 --- a/src/app/store/rooms/rooms.reducer.ts +++ b/src/app/store/rooms/rooms.reducer.ts @@ -121,6 +121,20 @@ export const roomsReducer = createReducer( isConnected: false, })), + // View server – just switch the viewed room, stay connected + on(RoomsActions.viewServer, (state) => ({ + ...state, + isConnecting: true, + error: null, + })), + + on(RoomsActions.viewServerSuccess, (state, { room }) => ({ + ...state, + currentRoom: room, + isConnecting: false, + isConnected: true, + })), + // Update room settings on(RoomsActions.updateRoomSettings, (state) => ({ ...state,