Multi server connection

This commit is contained in:
2026-03-01 15:26:08 +01:00
parent d88a476f15
commit d146138fca
7 changed files with 278 additions and 86 deletions

View File

@@ -38,7 +38,8 @@ interface JoinRequest {
interface ConnectedUser { interface ConnectedUser {
oderId: string; oderId: string;
ws: WebSocket; ws: WebSocket;
serverId?: string; serverIds: Set<string>; // all servers the user is a member of
viewedServerId?: string; // currently viewed/active server
displayName?: string; displayName?: string;
} }
@@ -361,7 +362,7 @@ const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => { wss.on('connection', (ws: WebSocket) => {
const connectionId = uuidv4(); const connectionId = uuidv4();
connectedUsers.set(connectionId, { oderId: connectionId, ws }); connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
ws.on('message', (data) => { ws.on('message', (data) => {
try { try {
@@ -374,13 +375,16 @@ wss.on('connection', (ws: WebSocket) => {
ws.on('close', () => { ws.on('close', () => {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
if (user?.serverId) { if (user) {
// Notify others in the room - use user.oderId (the actual user ID), not connectionId // Notify all servers the user was a member of
broadcastToServer(user.serverId, { user.serverIds.forEach((sid) => {
type: 'user_left', broadcastToServer(sid, {
oderId: user.oderId, type: 'user_left',
displayName: user.displayName, oderId: user.oderId,
}, user.oderId); displayName: user.displayName,
serverId: sid,
}, user.oderId);
});
} }
connectedUsers.delete(connectionId); connectedUsers.delete(connectionId);
}); });
@@ -403,44 +407,76 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
break; break;
case 'join_server': case 'join_server': {
user.serverId = message.serverId; const sid = message.serverId;
const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid);
user.viewedServerId = sid;
connectedUsers.set(connectionId, user); 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) // Always send the current user list for this server
// Only include users that have been identified (have displayName)
const usersInServer = Array.from(connectedUsers.values()) 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' })); .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer); console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
user.ws.send(JSON.stringify({ user.ws.send(JSON.stringify({
type: 'server_users', type: 'server_users',
serverId: sid,
users: usersInServer, users: usersInServer,
})); }));
// Notify others (exclude by oderId, not connectionId) // Only broadcast user_joined if this is a brand-new join (not a re-view)
broadcastToServer(message.serverId, { if (isNew) {
type: 'user_joined', broadcastToServer(sid, {
oderId: user.oderId, type: 'user_joined',
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',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName || 'Anonymous', displayName: user.displayName || 'Anonymous',
serverId: sid,
}, user.oderId); }, user.oderId);
} }
break; 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 'offer':
case 'answer': case 'answer':
@@ -460,11 +496,13 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
} }
break; break;
case 'chat_message': case 'chat_message': {
// Broadcast chat message to all users in the server // Broadcast chat message to all users in the server
if (user.serverId) { const chatSid = message.serverId || user.viewedServerId;
broadcastToServer(user.serverId, { if (chatSid && user.serverIds.has(chatSid)) {
broadcastToServer(chatSid, {
type: 'chat_message', type: 'chat_message',
serverId: chatSid,
message: message.message, message: message.message,
senderId: user.oderId, senderId: user.oderId,
senderName: user.displayName, senderName: user.displayName,
@@ -472,17 +510,21 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
}); });
} }
break; break;
}
case 'typing': case 'typing': {
// Broadcast typing indicator // Broadcast typing indicator
if (user.serverId) { const typingSid = message.serverId || user.viewedServerId;
broadcastToServer(user.serverId, { if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing', type: 'user_typing',
serverId: typingSid,
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName,
}, user.oderId); }, user.oderId);
} }
break; break;
}
default: default:
console.log('Unknown message type:', message.type); 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 { function broadcastToServer(serverId: string, message: any, excludeOderId?: string): void {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
connectedUsers.forEach((user) => { 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})`); console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
user.ws.send(JSON.stringify(message)); user.ws.send(JSON.stringify(message));
} }

View File

@@ -85,14 +85,19 @@ export class VoiceSessionService {
navigateToVoiceServer(): void { navigateToVoiceServer(): void {
const session = this._voiceSession(); const session = this._voiceSession();
if (session) { if (session) {
// Dispatch joinRoom action to update the store state // Use viewServer to switch view without leaving current server
this.store.dispatch(RoomsActions.joinRoom({ this.store.dispatch(RoomsActions.viewServer({
roomId: session.serverId, room: {
serverInfo: { id: session.serverId,
name: session.serverName, name: session.serverName,
description: session.serverDescription, description: session.serverDescription,
hostName: 'Unknown', hostId: '',
}, isPrivate: false,
createdAt: 0,
userCount: 0,
maxUsers: 50,
icon: session.serverIcon,
} as any,
})); }));
this._isViewingVoiceServer.set(true); this._isViewingVoiceServer.set(true);
} }

View File

@@ -43,6 +43,8 @@ export class WebRTCService {
private currentServerId: string | null = null; private currentServerId: string | null = null;
private lastIdentify: { oderId: string; displayName: string } | null = null; private lastIdentify: { oderId: string; displayName: string } | null = null;
private lastJoin: { serverId: string; userId: 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<string>();
// Signals for reactive state // Signals for reactive state
private readonly _peerId = signal<string>(uuidv4()); private readonly _peerId = signal<string>(uuidv4());
@@ -190,7 +192,22 @@ export class WebRTCService {
displayName: this.lastIdentify.displayName, 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({ this.sendRawMessage({
type: 'join_server', type: 'join_server',
serverId: this.lastJoin.serverId, serverId: this.lastJoin.serverId,
@@ -619,8 +636,12 @@ export class WebRTCService {
break; break;
case 'user_left': case 'user_left':
this.log('User left', { displayName: message.displayName, oderId: message.oderId }); this.log('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId });
this.removePeer(message.oderId); // 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; break;
case 'offer': 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 { joinRoom(roomId: string, userId: string): void {
this.lastJoin = { serverId: roomId, userId }; this.lastJoin = { serverId: roomId, userId };
this.joinedServerIds.add(roomId);
this.sendRawMessage({ this.sendRawMessage({
type: 'join_server', type: 'join_server',
serverId: roomId, serverId: roomId,
@@ -1172,23 +1194,30 @@ export class WebRTCService {
} }
/** /**
* Switch to a different server without disconnecting voice or peers. * Switch the viewed server without affecting multi-server membership.
* This is used when navigating between servers while already connected. * If the server hasn't been joined yet, performs a full join.
* If voice is connected, the user stays in voice in the original server. * If already joined, just sends view_server to refresh user list.
*/ */
switchServer(serverId: string, userId: string): void { switchServer(serverId: string, userId: string): void {
// Update the last join info for reconnection purposes // Update the last join info for reconnection purposes
this.lastJoin = { serverId, userId }; this.lastJoin = { serverId, userId };
// Send join_server to switch rooms on the signaling server if (this.joinedServerIds.has(serverId)) {
// This tells the server we're now in a different room, but doesn't // Already a member just switch the view
// affect our peer connections or voice state this.sendRawMessage({
this.sendRawMessage({ type: 'view_server',
type: 'join_server', serverId: serverId,
serverId: serverId, });
}); this.log('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() });
} else {
this.log('Switched server', { serverId, userId, voiceConnected: this._isVoiceConnected() }); // 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 { private scheduleReconnect(): void {
@@ -1329,12 +1358,37 @@ export class WebRTCService {
} }
} }
// Leave room // Leave a specific server (or all if no serverId given)
leaveRoom(): void { leaveRoom(serverId?: string): void {
this.sendRawMessage({ if (serverId) {
type: 'leave_server', // 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 // Clear all P2P reconnect timers
this.clearAllP2PReconnectTimers(); this.clearAllP2PReconnectTimers();
@@ -1353,9 +1407,19 @@ export class WebRTCService {
this.stopScreenShare(); 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<string> {
return this.joinedServerIds;
}
// Disconnect from signaling server // Disconnect from signaling server
disconnect(): void { disconnect(): void {
this.leaveRoom(); this.leaveRoom(); // leaves all servers
this.stopVoiceHeartbeat(); this.stopVoiceHeartbeat();
if (this.signalingSocket) { if (this.signalingSocket) {

View File

@@ -8,6 +8,7 @@ import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models'; import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service'; import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import * as RoomsActions from '../../store/rooms/rooms.actions'; import * as RoomsActions from '../../store/rooms/rooms.actions';
@Component({ @Component({
@@ -21,6 +22,7 @@ export class ServersRailComponent {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
private voiceSession = inject(VoiceSessionService); private voiceSession = inject(VoiceSessionService);
private webrtc = inject(WebRTCService);
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -52,8 +54,6 @@ export class ServersRailComponent {
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login // 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'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -64,21 +64,28 @@ export class ServersRailComponent {
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) { if (voiceServerId && voiceServerId !== room.id) {
// User is switching to a different server while connected to voice // 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); this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) { } else if (voiceServerId === room.id) {
// Navigating back to the voice-connected server // Navigating back to the voice-connected server
this.voiceSession.setViewingVoiceServer(true); this.voiceSession.setViewingVoiceServer(true);
} }
this.store.dispatch(RoomsActions.joinRoom({ // If we've already joined this server, just switch the view
roomId: room.id, // (no user_joined broadcast, no leave from other servers)
serverInfo: { if (this.webrtc.hasJoinedServer(room.id)) {
name: room.name, this.store.dispatch(RoomsActions.viewServer({ room }));
description: room.description, } else {
hostName: room.hostId || 'Unknown', // 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 { openContextMenu(evt: MouseEvent, room: Room): void {

View File

@@ -67,6 +67,17 @@ export const leaveRoom = createAction('[Rooms] Leave Room');
export const leaveRoomSuccess = createAction('[Rooms] Leave Room Success'); 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 // Delete room
export const deleteRoom = createAction( export const deleteRoom = createAction(
'[Rooms] Delete Room', '[Rooms] Delete Room',

View File

@@ -196,9 +196,8 @@ export class RoomsEffects {
// Check if already connected to signaling server // Check if already connected to signaling server
if (this.webrtc.isConnected()) { if (this.webrtc.isConnected()) {
// Already connected - just switch to the new server without reconnecting WebSocket // Already connected - join the new server (additive, multi-server)
// This preserves voice connections and peer state console.log('Already connected to signaling, joining room:', room.id);
console.log('Already connected to signaling, switching to room:', room.id);
this.webrtc.setCurrentServer(room.id); this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId); this.webrtc.switchServer(room.id, oderId);
} else { } else {
@@ -223,6 +222,37 @@ export class RoomsEffects {
{ dispatch: false } { 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 // Leave room
leaveRoom$ = createEffect(() => leaveRoom$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
@@ -280,10 +310,8 @@ export class RoomsEffects {
// Delete from local DB // Delete from local DB
this.db.deleteRoom(roomId); this.db.deleteRoom(roomId);
// If currently in this room, disconnect // Leave this specific server (doesn't affect other servers)
if (currentRoom?.id === roomId) { this.webrtc.leaveRoom(roomId);
this.webrtc.disconnectAll();
}
return of(RoomsActions.forgetRoomSuccess({ roomId })); return of(RoomsActions.forgetRoomSuccess({ roomId }));
}) })
@@ -451,12 +479,23 @@ export class RoomsEffects {
// Listen to WebRTC signaling messages for user presence // Listen to WebRTC signaling messages for user presence
signalingMessages$ = createEffect(() => signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe( this.webrtc.onSignalingMessage.pipe(
withLatestFrom(this.store.select(selectCurrentUser)), withLatestFrom(
mergeMap(([message, currentUser]: [any, any]) => { this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
),
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
const actions: any[] = []; const actions: any[] = [];
const myId = currentUser?.oderId || currentUser?.id; const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
if (message.type === 'server_users' && message.users) { 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) // Add all existing users to the store (excluding ourselves)
message.users.forEach((user: { oderId: string; displayName: string }) => { message.users.forEach((user: { oderId: string; displayName: string }) => {
// Don't add ourselves to the list // Don't add ourselves to the list
@@ -478,6 +517,11 @@ export class RoomsEffects {
} }
}); });
} else if (message.type === 'user_joined') { } 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 // Don't add ourselves to the list
if (message.oderId !== myId) { if (message.oderId !== myId) {
actions.push( actions.push(
@@ -496,6 +540,11 @@ export class RoomsEffects {
); );
} }
} else if (message.type === 'user_left') { } 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 })); actions.push(UsersActions.userLeft({ userId: message.oderId }));
} }

View File

@@ -121,6 +121,20 @@ export const roomsReducer = createReducer(
isConnected: false, 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 // Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({ on(RoomsActions.updateRoomSettings, (state) => ({
...state, ...state,