1567 lines
51 KiB
TypeScript
1567 lines
51 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
/* eslint-disable id-length */
|
|
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
|
|
import { Injectable, inject } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
import {
|
|
Actions,
|
|
createEffect,
|
|
ofType
|
|
} from '@ngrx/effects';
|
|
import { Store } from '@ngrx/store';
|
|
import {
|
|
of,
|
|
from,
|
|
EMPTY
|
|
} from 'rxjs';
|
|
import {
|
|
map,
|
|
mergeMap,
|
|
catchError,
|
|
withLatestFrom,
|
|
tap,
|
|
debounceTime,
|
|
switchMap,
|
|
filter
|
|
} from 'rxjs/operators';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { RoomsActions } from './rooms.actions';
|
|
import { UsersActions } from '../users/users.actions';
|
|
import { MessagesActions } from '../messages/messages.actions';
|
|
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
|
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
|
import { DatabaseService } from '../../core/services/database.service';
|
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
|
import {
|
|
CLIENT_UPDATE_REQUIRED_MESSAGE,
|
|
ServerDirectoryService,
|
|
ServerSourceSelector
|
|
} from '../../core/services/server-directory.service';
|
|
import {
|
|
ChatEvent,
|
|
Room,
|
|
RoomSettings,
|
|
RoomPermissions,
|
|
BanEntry,
|
|
User,
|
|
VoiceState
|
|
} from '../../core/models/index';
|
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
|
import { ROOM_URL_PATTERN } from '../../core/constants';
|
|
import {
|
|
findRoomMember,
|
|
removeRoomMember,
|
|
transferRoomOwnership
|
|
} from './room-members.helpers';
|
|
|
|
/** Build a minimal User object from signaling payload. */
|
|
function buildSignalingUser(
|
|
data: { oderId: string; displayName?: string },
|
|
extras: Record<string, unknown> = {}
|
|
) {
|
|
const displayName = data.displayName?.trim() || 'User';
|
|
|
|
return {
|
|
oderId: data.oderId,
|
|
id: data.oderId,
|
|
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
|
displayName,
|
|
status: 'online' as const,
|
|
isOnline: true,
|
|
role: 'member' as const,
|
|
joinedAt: Date.now(),
|
|
...extras
|
|
};
|
|
}
|
|
|
|
/** Best-known persisted member metadata for a signaling user in the viewed room. */
|
|
function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
|
|
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
|
|
|
|
if (!knownMember)
|
|
return {};
|
|
|
|
return {
|
|
username: knownMember.username,
|
|
avatarUrl: knownMember.avatarUrl,
|
|
role: knownMember.role,
|
|
joinedAt: knownMember.joinedAt
|
|
};
|
|
}
|
|
|
|
/** Returns true when the message's server ID does not match the viewed server. */
|
|
function isWrongServer(
|
|
msgServerId: string | undefined,
|
|
viewedServerId: string | undefined
|
|
): boolean {
|
|
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
|
}
|
|
|
|
function resolveUserDisplayName(user: Pick<User, 'displayName' | 'username'> | null | undefined): string {
|
|
const displayName = user?.displayName?.trim();
|
|
|
|
if (displayName) {
|
|
return displayName;
|
|
}
|
|
|
|
return user?.username?.trim() || 'User';
|
|
}
|
|
|
|
interface RoomPresenceSignalingMessage {
|
|
type: string;
|
|
reason?: string;
|
|
serverId?: string;
|
|
users?: { oderId: string; displayName: string }[];
|
|
oderId?: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
type BlockedRoomAccessAction =
|
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
|
| ReturnType<typeof RoomsActions.joinRoomFailure>;
|
|
|
|
@Injectable()
|
|
export class RoomsEffects {
|
|
private actions$ = inject(Actions);
|
|
private store = inject(Store);
|
|
private router = inject(Router);
|
|
private db = inject(DatabaseService);
|
|
private webrtc = inject(WebRTCService);
|
|
private serverDirectory = inject(ServerDirectoryService);
|
|
private audioService = inject(NotificationAudioService);
|
|
|
|
/**
|
|
* Tracks user IDs we already know are in voice. Lives outside the
|
|
* NgRx store so it survives `clearUsers()` dispatched on server switches
|
|
* and prevents false join/leave sounds during state re-syncs.
|
|
*/
|
|
private knownVoiceUsers = new Set<string>();
|
|
|
|
/** Loads all saved rooms from the local database. */
|
|
loadRooms$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.loadRooms),
|
|
switchMap(() =>
|
|
from(this.db.getAllRooms()).pipe(
|
|
map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })),
|
|
catchError((error) => of(RoomsActions.loadRoomsFailure({ error: error.message })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Searches the server directory with debounced input. */
|
|
searchServers$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.searchServers),
|
|
switchMap(({ query }) =>
|
|
this.serverDirectory.searchServers(query).pipe(
|
|
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
|
catchError((error) => of(RoomsActions.searchServersFailure({ error: error.message })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Reconnects saved rooms so joined servers stay online while the app is running. */
|
|
keepSavedRoomsConnected$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(
|
|
RoomsActions.loadRoomsSuccess,
|
|
RoomsActions.forgetRoomSuccess,
|
|
RoomsActions.deleteRoomSuccess,
|
|
UsersActions.loadCurrentUserSuccess,
|
|
UsersActions.setCurrentUser
|
|
),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
tap(([
|
|
, user,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
this.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
|
createRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.createRoom),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
switchMap(([{ name, description, topic, isPrivate, password, sourceId, sourceUrl }, currentUser]) => {
|
|
if (!currentUser) {
|
|
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
|
}
|
|
|
|
const allEndpoints = this.serverDirectory.servers();
|
|
const activeEndpoints = this.serverDirectory.activeServers();
|
|
const selectedEndpoint = allEndpoints.find((endpoint) =>
|
|
(sourceId && endpoint.id === sourceId)
|
|
|| (!!sourceUrl && endpoint.url === sourceUrl)
|
|
);
|
|
const endpoint = selectedEndpoint
|
|
?? activeEndpoints[0]
|
|
?? allEndpoints[0]
|
|
?? null;
|
|
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
|
|
const room: Room = {
|
|
id: uuidv4(),
|
|
name,
|
|
description,
|
|
topic,
|
|
hostId: currentUser.id,
|
|
password: normalizedPassword || undefined,
|
|
hasPassword: normalizedPassword.length > 0,
|
|
isPrivate: isPrivate ?? false,
|
|
createdAt: Date.now(),
|
|
userCount: 1,
|
|
maxUsers: 50,
|
|
sourceId: endpoint?.id,
|
|
sourceName: endpoint?.name,
|
|
sourceUrl: endpoint?.url
|
|
};
|
|
|
|
// Save to local DB
|
|
this.db.saveRoom(room);
|
|
|
|
// Register with central server (using the same room ID for discoverability)
|
|
this.serverDirectory
|
|
.registerServer({
|
|
id: room.id, // Use the same ID as the local room
|
|
name: room.name,
|
|
description: room.description,
|
|
ownerId: currentUser.id,
|
|
ownerPublicKey: currentUser.oderId,
|
|
hostName: currentUser.displayName,
|
|
password: normalizedPassword || null,
|
|
hasPassword: normalizedPassword.length > 0,
|
|
isPrivate: room.isPrivate,
|
|
userCount: 1,
|
|
maxUsers: room.maxUsers || 50,
|
|
tags: []
|
|
}, endpoint ? {
|
|
sourceId: endpoint.id,
|
|
sourceUrl: endpoint.url
|
|
} : undefined
|
|
)
|
|
.subscribe();
|
|
|
|
return of(RoomsActions.createRoomSuccess({ room }));
|
|
}),
|
|
catchError((error) => of(RoomsActions.createRoomFailure({ error: error.message })))
|
|
)
|
|
);
|
|
|
|
/** Joins an existing room by ID, resolving room data from local DB or server directory. */
|
|
joinRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.joinRoom),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
switchMap(([{ roomId, password, serverInfo }, currentUser]) => {
|
|
if (!currentUser) {
|
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
|
}
|
|
|
|
return from(this.getBlockedRoomAccessActions(roomId, currentUser)).pipe(
|
|
switchMap((blockedActions) => {
|
|
if (blockedActions.length > 0) {
|
|
return from(blockedActions);
|
|
}
|
|
|
|
// First check local DB
|
|
return from(this.db.getRoom(roomId)).pipe(
|
|
switchMap((room) => {
|
|
const sourceSelector = serverInfo
|
|
? {
|
|
sourceId: serverInfo.sourceId,
|
|
sourceUrl: serverInfo.sourceUrl
|
|
}
|
|
: undefined;
|
|
|
|
if (room) {
|
|
const resolvedRoom: Room = {
|
|
...room,
|
|
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
|
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
|
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
|
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
|
|
hasPassword:
|
|
typeof serverInfo?.hasPassword === 'boolean'
|
|
? serverInfo.hasPassword
|
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
|
|
};
|
|
|
|
this.db.updateRoom(room.id, {
|
|
sourceId: resolvedRoom.sourceId,
|
|
sourceName: resolvedRoom.sourceName,
|
|
sourceUrl: resolvedRoom.sourceUrl,
|
|
hasPassword: resolvedRoom.hasPassword,
|
|
isPrivate: resolvedRoom.isPrivate
|
|
});
|
|
|
|
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
|
|
}
|
|
|
|
// If not in local DB but we have server info from search, create a room entry
|
|
if (serverInfo) {
|
|
const newRoom: Room = {
|
|
id: roomId,
|
|
name: serverInfo.name,
|
|
description: serverInfo.description,
|
|
hostId: '', // Unknown, will be determined via signaling
|
|
hasPassword: !!serverInfo.hasPassword,
|
|
isPrivate: !!serverInfo.isPrivate,
|
|
createdAt: Date.now(),
|
|
userCount: 1,
|
|
maxUsers: 50,
|
|
sourceId: serverInfo.sourceId,
|
|
sourceName: serverInfo.sourceName,
|
|
sourceUrl: serverInfo.sourceUrl
|
|
};
|
|
|
|
// Save to local DB for future reference
|
|
this.db.saveRoom(newRoom);
|
|
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
|
}
|
|
|
|
// Try to get room info from server
|
|
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
|
|
switchMap((serverData) => {
|
|
if (serverData) {
|
|
const newRoom: Room = {
|
|
id: serverData.id,
|
|
name: serverData.name,
|
|
description: serverData.description,
|
|
hostId: serverData.ownerId || '',
|
|
hasPassword: !!serverData.hasPassword,
|
|
isPrivate: serverData.isPrivate,
|
|
createdAt: serverData.createdAt || Date.now(),
|
|
userCount: serverData.userCount,
|
|
maxUsers: serverData.maxUsers,
|
|
sourceId: serverData.sourceId,
|
|
sourceName: serverData.sourceName,
|
|
sourceUrl: serverData.sourceUrl
|
|
};
|
|
|
|
this.db.saveRoom(newRoom);
|
|
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
|
}
|
|
|
|
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
|
}),
|
|
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
|
|
);
|
|
}),
|
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
|
);
|
|
}),
|
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Navigates to the room view and establishes or reuses a signaling connection. */
|
|
navigateToRoom$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
|
tap(([
|
|
{ room },
|
|
user,
|
|
savedRooms
|
|
]) => {
|
|
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
|
showCompatibilityError: true
|
|
});
|
|
|
|
this.router.navigate(['/room', room.id]);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Switches the UI view to an already-joined server without leaving others. */
|
|
viewServer$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.viewServer),
|
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectSavedRooms)),
|
|
switchMap(([
|
|
{ room, skipBanCheck },
|
|
user,
|
|
savedRooms
|
|
]) => {
|
|
if (!user) {
|
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
|
}
|
|
|
|
const activateViewedRoom = () => {
|
|
const oderId = user.oderId || this.webrtc.peerId();
|
|
|
|
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
|
showCompatibilityError: true
|
|
});
|
|
|
|
this.router.navigate(['/room', room.id]);
|
|
return of(RoomsActions.viewServerSuccess({ room }));
|
|
};
|
|
|
|
if (skipBanCheck) {
|
|
return activateViewedRoom();
|
|
}
|
|
|
|
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
|
switchMap((blockedActions) => {
|
|
if (blockedActions.length > 0) {
|
|
return from(blockedActions);
|
|
}
|
|
|
|
return activateViewedRoom();
|
|
}),
|
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Reloads messages and users when the viewed server changes. */
|
|
onViewServerSuccess$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.viewServerSuccess),
|
|
mergeMap(({ room }) => [
|
|
UsersActions.clearUsers(),
|
|
MessagesActions.loadMessages({ roomId: room.id }),
|
|
UsersActions.loadBans()
|
|
])
|
|
)
|
|
);
|
|
|
|
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
|
leaveRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.leaveRoom),
|
|
map(() => RoomsActions.leaveRoomSuccess())
|
|
)
|
|
);
|
|
|
|
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
|
|
deleteRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.deleteRoom),
|
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
|
filter(
|
|
([
|
|
, currentUser,
|
|
currentRoom
|
|
]) => !!currentUser && currentRoom?.hostId === currentUser.id
|
|
),
|
|
switchMap(([{ roomId }]) => {
|
|
this.db.deleteRoom(roomId);
|
|
this.webrtc.broadcastMessage({ type: 'room-deleted',
|
|
roomId });
|
|
|
|
this.webrtc.disconnectAll();
|
|
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Leaves a server, optionally transfers ownership, and removes it locally. */
|
|
forgetRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.forgetRoom),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
switchMap(([
|
|
{ roomId, nextOwnerKey },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
const room = currentRoom?.id === roomId
|
|
? currentRoom
|
|
: (savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null);
|
|
const isRoomOwner = !!currentUser && !!room && (room.hostId === currentUser.id || room.hostId === currentUser.oderId);
|
|
|
|
if (currentUser && room && isRoomOwner) {
|
|
const nextOwner = nextOwnerKey
|
|
? (findRoomMember(room.members ?? [], nextOwnerKey) ?? null)
|
|
: null;
|
|
const updatedMembers = removeRoomMember(
|
|
transferRoomOwnership(
|
|
room.members ?? [],
|
|
nextOwner,
|
|
{
|
|
id: room.hostId,
|
|
oderId: currentUser.oderId
|
|
}
|
|
),
|
|
currentUser.id,
|
|
currentUser.oderId
|
|
);
|
|
const nextHostId = nextOwner?.id || nextOwner?.oderId || '';
|
|
const nextHostOderId = nextOwner?.oderId || '';
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'host-change',
|
|
roomId,
|
|
hostId: nextHostId,
|
|
hostOderId: nextHostOderId,
|
|
previousHostId: room.hostId,
|
|
previousHostOderId: currentUser.oderId,
|
|
members: updatedMembers
|
|
});
|
|
|
|
this.serverDirectory.updateServer(roomId, {
|
|
currentOwnerId: currentUser.id,
|
|
actingRole: 'host',
|
|
ownerId: nextHostId,
|
|
ownerPublicKey: nextHostOderId
|
|
}, {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
}).subscribe({
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
if (currentUser) {
|
|
this.webrtc.broadcastMessage({
|
|
type: 'member-leave',
|
|
roomId,
|
|
targetUserId: currentUser.id,
|
|
oderId: currentUser.oderId,
|
|
displayName: currentUser.displayName
|
|
});
|
|
}
|
|
|
|
if (currentUser && room) {
|
|
this.serverDirectory.notifyLeave(roomId, currentUser.id, {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
}).subscribe({
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
// Delete from local DB
|
|
this.db.deleteRoom(roomId);
|
|
|
|
// Leave this specific server (doesn't affect other servers)
|
|
this.webrtc.leaveRoom(roomId);
|
|
|
|
return currentRoom?.id === roomId
|
|
? [RoomsActions.leaveRoomSuccess(), RoomsActions.forgetRoomSuccess({ roomId })]
|
|
: of(RoomsActions.forgetRoomSuccess({ roomId }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
|
updateRoomSettings$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.updateRoomSettings),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
{ roomId, settings },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
if (!currentUser)
|
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not logged in' }));
|
|
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
|
|
|
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
|
|
const isOwner = currentUserRole === 'host';
|
|
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
|
|
|
|
if (!canManageRoom) {
|
|
return of(
|
|
RoomsActions.updateRoomSettingsFailure({
|
|
error: 'Permission denied'
|
|
})
|
|
);
|
|
}
|
|
|
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password');
|
|
const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined;
|
|
const nextHasPassword = typeof settings.hasPassword === 'boolean'
|
|
? settings.hasPassword
|
|
: (hasPasswordUpdate
|
|
? !!normalizedPassword
|
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password));
|
|
const updatedSettings: RoomSettings = {
|
|
name: settings.name ?? room.name,
|
|
description: settings.description ?? room.description,
|
|
topic: settings.topic ?? room.topic,
|
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
|
password: hasPasswordUpdate ? (normalizedPassword || '') : room.password,
|
|
hasPassword: nextHasPassword,
|
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
|
};
|
|
const localRoomUpdates: Partial<Room> = {
|
|
...updatedSettings,
|
|
password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password,
|
|
hasPassword: nextHasPassword
|
|
};
|
|
const sharedSettings: RoomSettings = {
|
|
name: updatedSettings.name,
|
|
description: updatedSettings.description,
|
|
topic: updatedSettings.topic,
|
|
isPrivate: updatedSettings.isPrivate,
|
|
hasPassword: nextHasPassword,
|
|
maxUsers: updatedSettings.maxUsers,
|
|
password: hasPasswordUpdate ? (normalizedPassword || '') : undefined
|
|
};
|
|
|
|
this.db.updateRoom(room.id, localRoomUpdates);
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'room-settings-update',
|
|
roomId: room.id,
|
|
settings: sharedSettings
|
|
});
|
|
|
|
if (canManageRoom) {
|
|
this.serverDirectory.updateServer(room.id, {
|
|
currentOwnerId: currentUser.id,
|
|
actingRole: currentUserRole ?? undefined,
|
|
name: updatedSettings.name,
|
|
description: updatedSettings.description,
|
|
isPrivate: updatedSettings.isPrivate,
|
|
maxUsers: updatedSettings.maxUsers,
|
|
password: hasPasswordUpdate ? (normalizedPassword || null) : undefined
|
|
}, {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
}).subscribe({
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
return of(RoomsActions.updateRoomSettingsSuccess({ roomId: room.id,
|
|
settings: updatedSettings }));
|
|
}),
|
|
catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message })))
|
|
)
|
|
);
|
|
|
|
/** Persists room field changes to the local database. */
|
|
updateRoom$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.updateRoom),
|
|
tap(({ roomId, changes }) => {
|
|
this.db.updateRoom(roomId, changes);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
|
updateRoomPermissions$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.updateRoomPermissions),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
{ roomId, permissions },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
if (!currentUser)
|
|
return EMPTY;
|
|
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return EMPTY;
|
|
|
|
const isOwner =
|
|
room.hostId === currentUser.id ||
|
|
room.hostId === currentUser.oderId ||
|
|
(currentRoom?.id === room.id && currentUser.role === 'host');
|
|
|
|
if (!isOwner)
|
|
return EMPTY;
|
|
|
|
const updated: Partial<Room> = {
|
|
permissions: { ...(room.permissions || {}),
|
|
...permissions } as RoomPermissions
|
|
};
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'room-permissions-update',
|
|
roomId: room.id,
|
|
permissions: updated.permissions
|
|
});
|
|
|
|
return of(RoomsActions.updateRoom({ roomId: room.id,
|
|
changes: updated }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
|
|
updateServerIcon$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.updateServerIcon),
|
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
|
mergeMap(([
|
|
{ roomId, icon },
|
|
currentUser,
|
|
currentRoom
|
|
]) => {
|
|
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
|
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
|
}
|
|
|
|
const role = currentUser.role;
|
|
const perms = currentRoom.permissions || {};
|
|
const isOwner = currentRoom.hostId === currentUser.id;
|
|
const canByRole =
|
|
(role === 'admin' && perms.adminsManageIcon) ||
|
|
(role === 'moderator' && perms.moderatorsManageIcon);
|
|
|
|
if (!isOwner && !canByRole) {
|
|
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
|
}
|
|
|
|
const iconUpdatedAt = Date.now();
|
|
const changes: Partial<Room> = { icon,
|
|
iconUpdatedAt };
|
|
|
|
this.db.updateRoom(roomId, changes);
|
|
// Broadcast to peers
|
|
this.webrtc.broadcastMessage({
|
|
type: 'server-icon-update',
|
|
roomId,
|
|
icon,
|
|
iconUpdatedAt
|
|
});
|
|
|
|
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
|
icon,
|
|
iconUpdatedAt }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Persists newly created room to the local database. */
|
|
persistRoomCreation$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.createRoomSuccess),
|
|
tap(({ room }) => {
|
|
this.db.saveRoom(room);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Set the creator's role to 'host' after creating a room. */
|
|
setHostRoleOnCreate$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.createRoomSuccess),
|
|
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } }))
|
|
)
|
|
);
|
|
|
|
/** Set the user's role to 'host' when rejoining a room they own. */
|
|
setHostRoleOnJoin$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.joinRoomSuccess),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
filter(([{ room }, user]) => !!user && !!room.hostId && room.hostId === user.id),
|
|
map(() => UsersActions.updateCurrentUser({ updates: { role: 'host' } }))
|
|
)
|
|
);
|
|
|
|
/** Loads messages and bans when joining a room. */
|
|
onJoinRoomSuccess$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.joinRoomSuccess),
|
|
// Don't load users from database - they come from signaling server.
|
|
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
|
)
|
|
);
|
|
|
|
/** Clears messages and users from the store when leaving a room. */
|
|
onLeaveRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.leaveRoomSuccess),
|
|
mergeMap(() => {
|
|
this.knownVoiceUsers.clear();
|
|
return [MessagesActions.clearMessages(), UsersActions.clearUsers()];
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
|
signalingMessages$ = createEffect(() =>
|
|
this.webrtc.onSignalingMessage.pipe(
|
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
|
mergeMap(([
|
|
message,
|
|
currentUser,
|
|
currentRoom
|
|
]) => {
|
|
const signalingMessage: RoomPresenceSignalingMessage = message;
|
|
const myId = currentUser?.oderId || currentUser?.id;
|
|
const viewedServerId = currentRoom?.id;
|
|
|
|
switch (signalingMessage.type) {
|
|
case 'server_users': {
|
|
if (!signalingMessage.users || isWrongServer(signalingMessage.serverId, viewedServerId))
|
|
return EMPTY;
|
|
|
|
const joinActions = signalingMessage.users
|
|
.filter((u) => u.oderId !== myId)
|
|
.map((u) =>
|
|
UsersActions.userJoined({
|
|
user: buildSignalingUser(u, buildKnownUserExtras(currentRoom, u.oderId))
|
|
})
|
|
);
|
|
|
|
return [
|
|
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
|
UsersActions.clearUsers(),
|
|
...joinActions
|
|
];
|
|
}
|
|
|
|
case 'user_joined': {
|
|
if (isWrongServer(signalingMessage.serverId, viewedServerId) || signalingMessage.oderId === myId)
|
|
return EMPTY;
|
|
|
|
if (!signalingMessage.oderId)
|
|
return EMPTY;
|
|
|
|
const joinedUser = {
|
|
oderId: signalingMessage.oderId,
|
|
displayName: signalingMessage.displayName
|
|
};
|
|
|
|
return [
|
|
RoomsActions.setSignalServerReconnecting({ isReconnecting: false }),
|
|
UsersActions.userJoined({
|
|
user: buildSignalingUser(joinedUser, buildKnownUserExtras(currentRoom, joinedUser.oderId))
|
|
})
|
|
];
|
|
}
|
|
|
|
case 'user_left': {
|
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
|
return EMPTY;
|
|
|
|
if (!signalingMessage.oderId)
|
|
return EMPTY;
|
|
|
|
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: false }), UsersActions.userLeft({ userId: signalingMessage.oderId })];
|
|
}
|
|
|
|
case 'access_denied': {
|
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
|
return EMPTY;
|
|
|
|
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
|
return EMPTY;
|
|
|
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
|
}
|
|
|
|
default:
|
|
return EMPTY;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Request a full room-state snapshot whenever a peer data channel opens. */
|
|
peerConnectedServerStateSync$ = createEffect(
|
|
() =>
|
|
this.webrtc.onPeerConnected.pipe(
|
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
|
tap(([peerId, room]) => {
|
|
if (!room)
|
|
return;
|
|
|
|
this.webrtc.sendToPeer(peerId, {
|
|
type: 'server-state-request',
|
|
roomId: room.id
|
|
});
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Re-request the latest room-state snapshot whenever the user enters or views a server. */
|
|
roomEntryServerStateSync$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(
|
|
RoomsActions.createRoomSuccess,
|
|
RoomsActions.joinRoomSuccess,
|
|
RoomsActions.viewServerSuccess
|
|
),
|
|
tap(({ room }) => {
|
|
for (const peerId of this.webrtc.getConnectedPeers()) {
|
|
try {
|
|
this.webrtc.sendToPeer(peerId, {
|
|
type: 'server-state-request',
|
|
roomId: room.id
|
|
});
|
|
} catch {
|
|
/* peer may have disconnected */
|
|
}
|
|
}
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Processes incoming P2P room-state, room-sync, and icon-sync events. */
|
|
incomingRoomEvents$ = createEffect(() =>
|
|
this.webrtc.onMessageReceived.pipe(
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms),
|
|
this.store.select(selectAllUsers),
|
|
this.store.select(selectCurrentUser)
|
|
),
|
|
mergeMap(([
|
|
event,
|
|
currentRoom,
|
|
savedRooms,
|
|
allUsers,
|
|
currentUser
|
|
]) => {
|
|
switch (event.type) {
|
|
case 'voice-state':
|
|
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'voice') : EMPTY;
|
|
case 'screen-state':
|
|
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'screen') : EMPTY;
|
|
case 'server-state-request':
|
|
return this.handleServerStateRequest(event, currentRoom, savedRooms);
|
|
case 'server-state-full':
|
|
return this.handleServerStateFull(event, currentRoom, savedRooms, currentUser ?? null);
|
|
case 'room-settings-update':
|
|
return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms);
|
|
case 'room-permissions-update':
|
|
return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms);
|
|
case 'server-icon-summary':
|
|
return this.handleIconSummary(event, currentRoom, savedRooms);
|
|
case 'server-icon-request':
|
|
return this.handleIconRequest(event, currentRoom, savedRooms);
|
|
case 'server-icon-full':
|
|
case 'server-icon-update':
|
|
return this.handleIconData(event, currentRoom, savedRooms);
|
|
default:
|
|
return EMPTY;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], kind: 'voice' | 'screen') {
|
|
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
|
|
|
if (!userId)
|
|
return EMPTY;
|
|
|
|
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
|
|
|
|
if (kind === 'voice') {
|
|
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
|
|
|
if (!vs)
|
|
return EMPTY;
|
|
|
|
// Detect voice-connection transitions to play join/leave sounds.
|
|
// Use the local knownVoiceUsers set (not the store) so that
|
|
// clearUsers() from server-switching doesn't create false transitions.
|
|
const weAreInVoice = this.webrtc.isVoiceConnected();
|
|
const nowConnected = vs.isConnected ?? false;
|
|
|
|
if (weAreInVoice) {
|
|
const wasKnown = this.knownVoiceUsers.has(userId);
|
|
|
|
if (!wasKnown && nowConnected) {
|
|
this.audioService.play(AppSound.Joining);
|
|
} else if (wasKnown && !nowConnected) {
|
|
this.audioService.play(AppSound.Leave);
|
|
}
|
|
}
|
|
|
|
// Keep the tracking set in sync
|
|
if (nowConnected) {
|
|
this.knownVoiceUsers.add(userId);
|
|
} else {
|
|
this.knownVoiceUsers.delete(userId);
|
|
}
|
|
|
|
if (!userExists) {
|
|
return of(
|
|
UsersActions.userJoined({
|
|
user: buildSignalingUser(
|
|
{ oderId: userId,
|
|
displayName: event.displayName || 'User' },
|
|
{
|
|
voiceState: {
|
|
isConnected: vs.isConnected ?? false,
|
|
isMuted: vs.isMuted ?? false,
|
|
isDeafened: vs.isDeafened ?? false,
|
|
isSpeaking: vs.isSpeaking ?? false,
|
|
isMutedByAdmin: vs.isMutedByAdmin,
|
|
volume: vs.volume,
|
|
roomId: vs.roomId,
|
|
serverId: vs.serverId
|
|
}
|
|
}
|
|
)
|
|
})
|
|
);
|
|
}
|
|
|
|
return of(UsersActions.updateVoiceState({ userId,
|
|
voiceState: vs }));
|
|
}
|
|
|
|
// screen-state
|
|
const isSharing = event.isScreenSharing as boolean | undefined;
|
|
|
|
if (isSharing === undefined)
|
|
return EMPTY;
|
|
|
|
if (!userExists) {
|
|
return of(
|
|
UsersActions.userJoined({
|
|
user: buildSignalingUser(
|
|
{ oderId: userId,
|
|
displayName: event.displayName || 'User' },
|
|
{ screenShareState: { isSharing } }
|
|
)
|
|
})
|
|
);
|
|
}
|
|
|
|
return of(
|
|
UsersActions.updateScreenShareState({
|
|
userId,
|
|
screenShareState: { isSharing }
|
|
})
|
|
);
|
|
}
|
|
|
|
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
|
if (!roomId)
|
|
return currentRoom;
|
|
|
|
if (currentRoom?.id === roomId)
|
|
return currentRoom;
|
|
|
|
return savedRooms.find((room) => room.id === roomId) ?? null;
|
|
}
|
|
|
|
private sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
|
|
return {
|
|
name: typeof room.name === 'string' ? room.name : undefined,
|
|
description: typeof room.description === 'string' ? room.description : undefined,
|
|
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
|
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
|
hasPassword:
|
|
typeof room.hasPassword === 'boolean'
|
|
? room.hasPassword
|
|
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
|
|
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
|
|
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
|
|
icon: typeof room.icon === 'string' ? room.icon : undefined,
|
|
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
|
permissions: room.permissions ? { ...room.permissions } : undefined,
|
|
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
|
members: Array.isArray(room.members) ? room.members : undefined,
|
|
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
|
|
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
|
|
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
|
|
};
|
|
}
|
|
|
|
private normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
|
|
if (!Array.isArray(bans))
|
|
return [];
|
|
|
|
const now = Date.now();
|
|
|
|
return bans
|
|
.filter((ban): ban is Partial<BanEntry> => !!ban && typeof ban === 'object')
|
|
.map((ban) => ({
|
|
oderId: typeof ban.oderId === 'string' ? ban.oderId : uuidv4(),
|
|
userId: typeof ban.userId === 'string' ? ban.userId : '',
|
|
roomId,
|
|
bannedBy: typeof ban.bannedBy === 'string' ? ban.bannedBy : '',
|
|
displayName: typeof ban.displayName === 'string' ? ban.displayName : undefined,
|
|
reason: typeof ban.reason === 'string' ? ban.reason : undefined,
|
|
expiresAt: typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined,
|
|
timestamp: typeof ban.timestamp === 'number' ? ban.timestamp : now
|
|
}))
|
|
.filter((ban) => !!ban.userId && !!ban.bannedBy && (!ban.expiresAt || ban.expiresAt > now));
|
|
}
|
|
|
|
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
|
|
return from(this.db.getBansForRoom(roomId)).pipe(
|
|
switchMap((localBans) => {
|
|
const nextIds = new Set(bans.map((ban) => ban.oderId));
|
|
const removals = localBans
|
|
.filter((ban) => !nextIds.has(ban.oderId))
|
|
.map((ban) => this.db.removeBan(ban.oderId));
|
|
const saves = bans.map((ban) => this.db.saveBan({ ...ban,
|
|
roomId }));
|
|
|
|
return from(Promise.all([...removals, ...saves]));
|
|
})
|
|
);
|
|
}
|
|
|
|
private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const fromPeerId = event.fromPeerId;
|
|
|
|
if (!room || !fromPeerId)
|
|
return EMPTY;
|
|
|
|
return from(this.db.getBansForRoom(room.id)).pipe(
|
|
tap((bans) => {
|
|
this.webrtc.sendToPeer(fromPeerId, {
|
|
type: 'server-state-full',
|
|
roomId: room.id,
|
|
room: this.sanitizeRoomSnapshot(room),
|
|
bans
|
|
});
|
|
}),
|
|
mergeMap(() => EMPTY)
|
|
);
|
|
}
|
|
|
|
private handleServerStateFull(
|
|
event: ChatEvent,
|
|
currentRoom: Room | null,
|
|
savedRooms: Room[],
|
|
currentUser: { id: string; oderId: string } | null
|
|
) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
|
|
|
if (!room || !incomingRoom)
|
|
return EMPTY;
|
|
|
|
const roomChanges = this.sanitizeRoomSnapshot(incomingRoom);
|
|
const bans = this.normalizeIncomingBans(room.id, event.bans);
|
|
|
|
return this.syncBansToLocalRoom(room.id, bans).pipe(
|
|
mergeMap(() => {
|
|
const actions: (ReturnType<typeof RoomsActions.updateRoom>
|
|
| ReturnType<typeof UsersActions.loadBansSuccess>
|
|
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [
|
|
RoomsActions.updateRoom({
|
|
roomId: room.id,
|
|
changes: roomChanges
|
|
})
|
|
];
|
|
const isCurrentUserBanned = hasRoomBanForUser(
|
|
bans,
|
|
currentUser,
|
|
this.getPersistedCurrentUserId()
|
|
);
|
|
|
|
if (currentRoom?.id === room.id) {
|
|
actions.push(UsersActions.loadBansSuccess({ bans }));
|
|
}
|
|
|
|
if (isCurrentUserBanned) {
|
|
actions.push(RoomsActions.forgetRoom({ roomId: room.id }));
|
|
}
|
|
|
|
return actions;
|
|
}),
|
|
catchError(() => EMPTY)
|
|
);
|
|
}
|
|
|
|
private handleRoomSettingsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const settings = event.settings as Partial<RoomSettings> | undefined;
|
|
|
|
if (!room || !settings)
|
|
return EMPTY;
|
|
|
|
return of(
|
|
RoomsActions.updateRoom({
|
|
roomId: room.id,
|
|
changes: {
|
|
name: settings.name ?? room.name,
|
|
description: settings.description ?? room.description,
|
|
topic: settings.topic ?? room.topic,
|
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
|
password: settings.password === '' ? undefined : room.password,
|
|
hasPassword:
|
|
typeof settings.hasPassword === 'boolean'
|
|
? settings.hasPassword
|
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
private handleRoomPermissionsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
|
|
|
if (!room || !permissions)
|
|
return EMPTY;
|
|
|
|
return of(
|
|
RoomsActions.updateRoom({
|
|
roomId: room.id,
|
|
changes: {
|
|
permissions: { ...(room.permissions || {}),
|
|
...permissions } as RoomPermissions
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return EMPTY;
|
|
|
|
const remoteUpdated = event.iconUpdatedAt || 0;
|
|
const localUpdated = room.iconUpdatedAt || 0;
|
|
|
|
if (remoteUpdated > localUpdated && event.fromPeerId) {
|
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
type: 'server-icon-request',
|
|
roomId: room.id
|
|
});
|
|
}
|
|
|
|
return EMPTY;
|
|
}
|
|
|
|
private handleIconRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return EMPTY;
|
|
|
|
if (event.fromPeerId) {
|
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
type: 'server-icon-full',
|
|
roomId: room.id,
|
|
icon: room.icon,
|
|
iconUpdatedAt: room.iconUpdatedAt || 0
|
|
});
|
|
}
|
|
|
|
return EMPTY;
|
|
}
|
|
|
|
private handleIconData(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const senderId = event.fromPeerId;
|
|
|
|
if (!room || typeof event.icon !== 'string' || !senderId)
|
|
return EMPTY;
|
|
|
|
return this.store.select(selectAllUsers).pipe(
|
|
map((users) => users.find((u) => u.id === senderId)),
|
|
mergeMap((sender) => {
|
|
if (!sender)
|
|
return EMPTY;
|
|
|
|
const perms = room.permissions || {};
|
|
const isOwner = room.hostId === sender.id;
|
|
const canByRole =
|
|
(sender.role === 'admin' && perms.adminsManageIcon) ||
|
|
(sender.role === 'moderator' && perms.moderatorsManageIcon);
|
|
|
|
if (!isOwner && !canByRole)
|
|
return EMPTY;
|
|
|
|
const updates: Partial<Room> = {
|
|
icon: event.icon,
|
|
iconUpdatedAt: event.iconUpdatedAt || Date.now()
|
|
};
|
|
|
|
this.db.updateRoom(room.id, updates);
|
|
return of(RoomsActions.updateRoom({ roomId: room.id,
|
|
changes: updates }));
|
|
})
|
|
);
|
|
}
|
|
|
|
/** Broadcasts the local server icon summary to peers when a new peer connects. */
|
|
peerConnectedIconSync$ = createEffect(
|
|
() =>
|
|
this.webrtc.onPeerConnected.pipe(
|
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
|
tap(([peerId, room]) => {
|
|
if (!room)
|
|
return;
|
|
|
|
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
|
|
|
this.webrtc.broadcastMessage({
|
|
type: 'server-icon-summary',
|
|
roomId: room.id,
|
|
iconUpdatedAt
|
|
});
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
private async connectToRoomSignaling(
|
|
room: Room,
|
|
user: User | null,
|
|
resolvedOderId?: string,
|
|
savedRooms: Room[] = [],
|
|
options: { showCompatibilityError?: boolean } = {}
|
|
): Promise<void> {
|
|
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
|
const compatibilitySelector = this.resolveCompatibilitySelector(room);
|
|
const isCompatible = compatibilitySelector === null
|
|
? true
|
|
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
|
|
|
|
if (!isCompatible) {
|
|
if (shouldShowCompatibilityError) {
|
|
this.store.dispatch(
|
|
RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE })
|
|
);
|
|
}
|
|
|
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
|
return;
|
|
}
|
|
|
|
if (shouldShowCompatibilityError) {
|
|
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
|
|
}
|
|
|
|
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
});
|
|
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
|
|
const displayName = resolveUserDisplayName(user);
|
|
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
|
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
|
const joinCurrentEndpointRooms = () => {
|
|
this.webrtc.setCurrentServer(room.id);
|
|
this.webrtc.identify(oderId, displayName, wsUrl);
|
|
|
|
for (const backgroundRoom of backgroundRooms) {
|
|
if (!this.webrtc.hasJoinedServer(backgroundRoom.id)) {
|
|
this.webrtc.joinRoom(backgroundRoom.id, oderId, wsUrl);
|
|
}
|
|
}
|
|
|
|
if (this.webrtc.hasJoinedServer(room.id)) {
|
|
this.webrtc.switchServer(room.id, oderId, wsUrl);
|
|
} else {
|
|
this.webrtc.joinRoom(room.id, oderId, wsUrl);
|
|
}
|
|
};
|
|
|
|
if (this.webrtc.isSignalingConnectedTo(wsUrl)) {
|
|
joinCurrentEndpointRooms();
|
|
return;
|
|
}
|
|
|
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
|
next: (connected) => {
|
|
if (!connected)
|
|
return;
|
|
|
|
joinCurrentEndpointRooms();
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
private syncSavedRoomConnections(user: User | null, currentRoom: Room | null, savedRooms: Room[]): void {
|
|
if (!user || savedRooms.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const watchedRoomId = this.extractRoomIdFromUrl(this.router.url);
|
|
const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms;
|
|
const roomsBySignalingUrl = new Map<string, Room[]>();
|
|
|
|
for (const room of roomsToSync) {
|
|
const wsUrl = this.serverDirectory.getWebSocketUrl({
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
});
|
|
const groupedRooms = roomsBySignalingUrl.get(wsUrl) ?? [];
|
|
|
|
if (!groupedRooms.some((groupedRoom) => groupedRoom.id === room.id)) {
|
|
groupedRooms.push(room);
|
|
}
|
|
|
|
roomsBySignalingUrl.set(wsUrl, groupedRooms);
|
|
}
|
|
|
|
for (const groupedRooms of roomsBySignalingUrl.values()) {
|
|
const preferredRoom = groupedRooms.find((room) => room.id === watchedRoomId)
|
|
?? (currentRoom && groupedRooms.some((room) => room.id === currentRoom.id)
|
|
? currentRoom
|
|
: null)
|
|
?? groupedRooms[0]
|
|
?? null;
|
|
|
|
if (!preferredRoom) {
|
|
continue;
|
|
}
|
|
|
|
const shouldShowCompatibilityError = preferredRoom.id === watchedRoomId
|
|
|| (!!currentRoom && preferredRoom.id === currentRoom.id);
|
|
|
|
void this.connectToRoomSignaling(preferredRoom, user, user.oderId || this.webrtc.peerId(), roomsToSync, {
|
|
showCompatibilityError: shouldShowCompatibilityError
|
|
});
|
|
}
|
|
}
|
|
|
|
private resolveCompatibilitySelector(room: Room): ServerSourceSelector | undefined | null {
|
|
if (room.sourceId) {
|
|
const endpointById = this.serverDirectory.servers().find((entry) => entry.id === room.sourceId);
|
|
|
|
if (endpointById) {
|
|
return { sourceId: room.sourceId };
|
|
}
|
|
|
|
if (room.sourceUrl && this.serverDirectory.findServerByUrl(room.sourceUrl)) {
|
|
return { sourceUrl: room.sourceUrl };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (room.sourceUrl) {
|
|
return this.serverDirectory.findServerByUrl(room.sourceUrl)
|
|
? { sourceUrl: room.sourceUrl }
|
|
: null;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private includeRoom(rooms: Room[], room: Room): Room[] {
|
|
return rooms.some((candidate) => candidate.id === room.id)
|
|
? rooms
|
|
: [...rooms, room];
|
|
}
|
|
|
|
private getRoomsForSignalingUrl(rooms: Room[], wsUrl: string): Room[] {
|
|
const seenRoomIds = new Set<string>();
|
|
const matchingRooms: Room[] = [];
|
|
|
|
for (const room of rooms) {
|
|
if (seenRoomIds.has(room.id)) {
|
|
continue;
|
|
}
|
|
|
|
if (this.serverDirectory.getWebSocketUrl({
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
}) !== wsUrl) {
|
|
continue;
|
|
}
|
|
|
|
seenRoomIds.add(room.id);
|
|
matchingRooms.push(room);
|
|
}
|
|
|
|
return matchingRooms;
|
|
}
|
|
|
|
private extractRoomIdFromUrl(url: string): string | null {
|
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
|
|
|
return roomMatch ? roomMatch[1] : null;
|
|
}
|
|
|
|
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
|
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
|
return 'host';
|
|
|
|
if (currentRoom?.id === room.id && currentUser.role)
|
|
return currentUser.role;
|
|
|
|
return findRoomMember(room.members ?? [], currentUser.id)?.role
|
|
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
|
|| null;
|
|
}
|
|
|
|
private getPersistedCurrentUserId(): string | null {
|
|
return localStorage.getItem('metoyou_currentUserId');
|
|
}
|
|
|
|
private async getBlockedRoomAccessActions(
|
|
roomId: string,
|
|
currentUser: { id: string; oderId: string } | null
|
|
): Promise<BlockedRoomAccessAction[]> {
|
|
const bans = await this.db.getBansForRoom(roomId);
|
|
|
|
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
|
|
return [];
|
|
}
|
|
|
|
const blockedActions: BlockedRoomAccessAction[] = [RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })];
|
|
const storedRoom = await this.db.getRoom(roomId);
|
|
|
|
if (storedRoom) {
|
|
blockedActions.unshift(RoomsActions.forgetRoom({ roomId }));
|
|
}
|
|
|
|
return blockedActions;
|
|
}
|
|
}
|