refactor: Cleaning rooms store
This commit is contained in:
@@ -19,6 +19,8 @@ import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
|||||||
import { UsersEffects } from './store/users/users.effects';
|
import { UsersEffects } from './store/users/users.effects';
|
||||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||||
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
|
||||||
|
import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects';
|
||||||
|
import { RoomSettingsEffects } from './store/rooms/room-settings.effects';
|
||||||
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
|
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
|
||||||
|
|
||||||
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
|
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
|
||||||
@@ -38,7 +40,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
UsersEffects,
|
UsersEffects,
|
||||||
RoomsEffects,
|
RoomsEffects,
|
||||||
RoomMembersSyncEffects
|
RoomMembersSyncEffects,
|
||||||
|
RoomStateSyncEffects,
|
||||||
|
RoomSettingsEffects
|
||||||
]),
|
]),
|
||||||
provideStoreDevtools({
|
provideStoreDevtools({
|
||||||
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||||
|
|||||||
346
toju-app/src/app/store/rooms/room-settings.effects.ts
Normal file
346
toju-app/src/app/store/rooms/room-settings.effects.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { of, EMPTY } from 'rxjs';
|
||||||
|
import {
|
||||||
|
mergeMap,
|
||||||
|
withLatestFrom,
|
||||||
|
tap,
|
||||||
|
catchError
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
import { RoomsActions } from './rooms.actions';
|
||||||
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
|
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||||
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
|
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||||
|
import {
|
||||||
|
normalizeRoomAccessControl,
|
||||||
|
resolveRoomPermission,
|
||||||
|
withLegacyRoomPermissions
|
||||||
|
} from '../../domains/access-control';
|
||||||
|
import { Room, RoomSettings } from '../../shared-kernel';
|
||||||
|
import {
|
||||||
|
resolveRoom,
|
||||||
|
getUserRoleForRoom,
|
||||||
|
canManageChannelsInRoom
|
||||||
|
} from './rooms.helpers';
|
||||||
|
import { defaultChannels } from './room-channels.defaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NgRx effects for room settings, permissions, channels, and icon updates.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RoomSettingsEffects {
|
||||||
|
private actions$ = inject(Actions);
|
||||||
|
private store = inject(Store);
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
/** 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 = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
||||||
|
|
||||||
|
const currentUserRole = getUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
|
const isOwner = currentUserRole === 'host';
|
||||||
|
const canManageRoom = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
|
||||||
|
|
||||||
|
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 and broadcasts channel add/remove/rename changes. */
|
||||||
|
syncChannelChanges$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.addChannel, RoomsActions.removeChannel, RoomsActions.renameChannel),
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom)
|
||||||
|
),
|
||||||
|
tap(([
|
||||||
|
, currentUser,
|
||||||
|
currentRoom
|
||||||
|
]) => {
|
||||||
|
if (!currentUser || !currentRoom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = getUserRoleForRoom(currentRoom, currentUser, currentRoom);
|
||||||
|
|
||||||
|
if (!canManageChannelsInRoom(currentRoom, currentUser, currentRoom, role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = currentRoom.channels ?? defaultChannels();
|
||||||
|
|
||||||
|
this.db.updateRoom(currentRoom.id, { channels });
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'channels-update',
|
||||||
|
roomId: currentRoom.id,
|
||||||
|
channels
|
||||||
|
});
|
||||||
|
|
||||||
|
this.serverDirectory.updateServer(currentRoom.id, {
|
||||||
|
currentOwnerId: currentUser.id,
|
||||||
|
actingRole: role ?? undefined,
|
||||||
|
channels
|
||||||
|
}, {
|
||||||
|
sourceId: currentRoom.sourceId,
|
||||||
|
sourceUrl: currentRoom.sourceUrl
|
||||||
|
}).subscribe({
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ 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 = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const nextRoom = withLegacyRoomPermissions(room, permissions);
|
||||||
|
|
||||||
|
return of(RoomsActions.updateRoomAccessControl({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: {
|
||||||
|
roles: nextRoom.roles,
|
||||||
|
roleAssignments: nextRoom.roleAssignments,
|
||||||
|
channelPermissions: nextRoom.channelPermissions,
|
||||||
|
slowModeInterval: nextRoom.slowModeInterval
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Updates role-based access control and broadcasts to peers and directory. */
|
||||||
|
updateRoomAccessControl$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.updateRoomAccessControl),
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
{ roomId, changes },
|
||||||
|
currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
if (!currentUser)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const requiresRoleManagement = !!changes.roles || !!changes.roleAssignments || !!changes.channelPermissions;
|
||||||
|
const requiresServerManagement = typeof changes.slowModeInterval === 'number';
|
||||||
|
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||||
|
const canManageRoles = isOwner || resolveRoomPermission(room, currentUser, 'manageRoles');
|
||||||
|
const canManageServer = isOwner || resolveRoomPermission(room, currentUser, 'manageServer');
|
||||||
|
|
||||||
|
if ((requiresRoleManagement && !canManageRoles) || (requiresServerManagement && !canManageServer)) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRoom = normalizeRoomAccessControl({
|
||||||
|
...room,
|
||||||
|
...changes
|
||||||
|
});
|
||||||
|
const nextChanges: Partial<Room> = {
|
||||||
|
roles: nextRoom.roles,
|
||||||
|
roleAssignments: nextRoom.roleAssignments,
|
||||||
|
channelPermissions: nextRoom.channelPermissions,
|
||||||
|
slowModeInterval: nextRoom.slowModeInterval,
|
||||||
|
permissions: nextRoom.permissions,
|
||||||
|
members: nextRoom.members
|
||||||
|
};
|
||||||
|
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'room-permissions-update',
|
||||||
|
roomId: room.id,
|
||||||
|
permissions: nextRoom.permissions,
|
||||||
|
room: {
|
||||||
|
roles: nextRoom.roles,
|
||||||
|
roleAssignments: nextRoom.roleAssignments,
|
||||||
|
channelPermissions: nextRoom.channelPermissions,
|
||||||
|
slowModeInterval: nextRoom.slowModeInterval
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.serverDirectory.updateServer(room.id, {
|
||||||
|
currentOwnerId: currentUser.id,
|
||||||
|
roles: nextRoom.roles,
|
||||||
|
roleAssignments: nextRoom.roleAssignments,
|
||||||
|
channelPermissions: nextRoom.channelPermissions,
|
||||||
|
slowModeInterval: nextRoom.slowModeInterval
|
||||||
|
}, {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
}).subscribe({
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: nextChanges }));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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 isOwner = currentRoom.hostId === currentUser.id;
|
||||||
|
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'server-icon-update',
|
||||||
|
roomId,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
return of(RoomsActions.updateServerIconSuccess({ roomId,
|
||||||
|
icon,
|
||||||
|
iconUpdatedAt }));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
453
toju-app/src/app/store/rooms/room-signaling-connection.ts
Normal file
453
toju-app/src/app/store/rooms/room-signaling-connection.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { Room, User } from '../../shared-kernel';
|
||||||
|
import {
|
||||||
|
type RoomSignalSource,
|
||||||
|
type ServerSourceSelector,
|
||||||
|
type ServerDirectoryFacade,
|
||||||
|
areRoomSignalSourcesEqual,
|
||||||
|
CLIENT_UPDATE_REQUIRED_MESSAGE
|
||||||
|
} from '../../domains/server-directory';
|
||||||
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
|
import { RoomsActions } from './rooms.actions';
|
||||||
|
import { resolveUserDisplayName, extractRoomIdFromUrl } from './rooms.helpers';
|
||||||
|
|
||||||
|
export interface RoomSignalConnectionPlan {
|
||||||
|
fallbackSources: RoomSignalSource[];
|
||||||
|
primarySource: RoomSignalSource | null;
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates signaling-server connection, fallback cascade, and
|
||||||
|
* navigation versioning logic that was previously inlined in
|
||||||
|
* {@link RoomsEffects}.
|
||||||
|
*
|
||||||
|
* Instantiated by `RoomsEffects` (not an Angular service) - it receives
|
||||||
|
* the required dependencies through its constructor.
|
||||||
|
*/
|
||||||
|
export class RoomSignalingConnection {
|
||||||
|
latestNavigatedRoomId: string | null = null;
|
||||||
|
private readonly roomSignalFallbackSources = new Map<string, RoomSignalSource>();
|
||||||
|
private roomNavigationRequestVersion = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly webrtc: RealtimeSessionFacade,
|
||||||
|
private readonly serverDirectory: ServerDirectoryFacade,
|
||||||
|
private readonly store: Store
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Navigation versioning ──────────────────────────────────────
|
||||||
|
|
||||||
|
beginRoomNavigation(roomId: string): number {
|
||||||
|
this.roomNavigationRequestVersion += 1;
|
||||||
|
this.latestNavigatedRoomId = roomId;
|
||||||
|
|
||||||
|
return this.roomNavigationRequestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentRoomNavigation(roomId: string, navigationRequestVersion?: number): boolean {
|
||||||
|
if (typeof navigationRequestVersion !== 'number') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigationRequestVersion === this.roomNavigationRequestVersion
|
||||||
|
&& roomId === this.latestNavigatedRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRoomFallbackSource(roomId: string): void {
|
||||||
|
this.roomSignalFallbackSources.delete(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Primary connection entry points ────────────────────────────
|
||||||
|
|
||||||
|
async connectToRoomSignaling(
|
||||||
|
room: Room,
|
||||||
|
user: User | null,
|
||||||
|
resolvedOderId?: string,
|
||||||
|
savedRooms: Room[] = [],
|
||||||
|
options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
||||||
|
const navigationRequestVersion = options.navigationRequestVersion;
|
||||||
|
const isViewedRoom = () => room.id === this.latestNavigatedRoomId;
|
||||||
|
|
||||||
|
await this.serverDirectory.awaitInitialServerHealthCheck();
|
||||||
|
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionPlan = await this.resolveRoomSignalConnectionPlan(room);
|
||||||
|
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionFallbackSource = this.roomSignalFallbackSources.get(room.id);
|
||||||
|
const connectionCandidates: {
|
||||||
|
isExistingFallback?: boolean;
|
||||||
|
isFallback?: boolean;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
source: RoomSignalSource;
|
||||||
|
}[] = [];
|
||||||
|
const pushConnectionCandidate = (
|
||||||
|
source: RoomSignalSource | null | undefined,
|
||||||
|
flags: { isExistingFallback?: boolean; isFallback?: boolean; isPrimary?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
if (!source || !this.resolveRoomSignalSelector(source, room.name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionCandidates.some((candidate) => areRoomSignalSourcesEqual(candidate.source, source))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionCandidates.push({
|
||||||
|
...flags,
|
||||||
|
source
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionFallbackSource && this.webrtc.hasJoinedServer(room.id)) {
|
||||||
|
pushConnectionCandidate(sessionFallbackSource, { isExistingFallback: true, isFallback: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
pushConnectionCandidate(connectionPlan.primarySource, { isPrimary: true });
|
||||||
|
|
||||||
|
for (const fallbackSource of connectionPlan.fallbackSources) {
|
||||||
|
pushConnectionCandidate(fallbackSource, { isFallback: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let attemptedFallback = false;
|
||||||
|
|
||||||
|
for (const candidate of connectionCandidates) {
|
||||||
|
const selector = this.resolveRoomSignalSelector(candidate.source, connectionPlan.room.name);
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCompatible = await this.serverDirectory.ensureEndpointVersionCompatibility(selector);
|
||||||
|
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCompatible) {
|
||||||
|
if (candidate.isPrimary) {
|
||||||
|
if (shouldShowCompatibilityError) {
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.setSignalServerCompatibilityError({ message: CLIENT_UPDATE_REQUIRED_MESSAGE })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViewedRoom()) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.isFallback && !candidate.isExistingFallback && !attemptedFallback) {
|
||||||
|
attemptedFallback = true;
|
||||||
|
|
||||||
|
if (isViewedRoom()) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = await this.connectRoomToSignalSource(
|
||||||
|
connectionPlan.room,
|
||||||
|
candidate.source,
|
||||||
|
user,
|
||||||
|
resolvedOderId,
|
||||||
|
savedRooms,
|
||||||
|
navigationRequestVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.isFallback) {
|
||||||
|
this.roomSignalFallbackSources.set(room.id, candidate.source);
|
||||||
|
} else {
|
||||||
|
this.roomSignalFallbackSources.delete(room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowCompatibilityError) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViewedRoom()) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShowCompatibilityError) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerCompatibilityError({ message: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViewedRoom()) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSavedRoomConnections(
|
||||||
|
user: User | null,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[],
|
||||||
|
currentUrl: string
|
||||||
|
): void {
|
||||||
|
if (!user || savedRooms.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchedRoomId = extractRoomIdFromUrl(currentUrl);
|
||||||
|
const roomsToSync = currentRoom ? this.includeRoom(savedRooms, currentRoom) : savedRooms;
|
||||||
|
const roomsBySignalingUrl = new Map<string, Room[]>();
|
||||||
|
|
||||||
|
for (const room of roomsToSync) {
|
||||||
|
const wsUrl = this.resolveRoomSignalingUrl(room);
|
||||||
|
|
||||||
|
if (!wsUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveRoomSignalingUrl(room: Room): string {
|
||||||
|
const selector = this.resolveRoomSignalSelector(this.getPreferredRoomSignalSource(room), room.name);
|
||||||
|
|
||||||
|
return selector ? this.serverDirectory.getWebSocketUrl(selector) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveRoomSignalSource(
|
||||||
|
room: Pick<Room, 'name' | 'sourceId' | 'sourceName' | 'sourceUrl'>
|
||||||
|
): RoomSignalSource {
|
||||||
|
return this.serverDirectory.normaliseRoomSignalSource({
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceName: room.sourceName,
|
||||||
|
sourceUrl: room.sourceUrl,
|
||||||
|
fallbackName: room.sourceName ?? room.name
|
||||||
|
}, {
|
||||||
|
ensureEndpoint: !!room.sourceUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveRoomSignalSelector(
|
||||||
|
source: RoomSignalSource | null | undefined,
|
||||||
|
fallbackName: string
|
||||||
|
): ServerSourceSelector | undefined {
|
||||||
|
if (!source) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serverDirectory.buildRoomSignalSelector({
|
||||||
|
...source,
|
||||||
|
fallbackName: source.sourceName ?? fallbackName
|
||||||
|
}, {
|
||||||
|
ensureEndpoint: !!source.sourceUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private async resolveRoomSignalConnectionPlan(room: Room): Promise<RoomSignalConnectionPlan> {
|
||||||
|
let resolvedRoom = this.repairRoomSignalSource(room, this.resolveRoomSignalSource(room));
|
||||||
|
let primarySource = this.resolveRoomSignalSource(resolvedRoom);
|
||||||
|
|
||||||
|
if (!this.webrtc.hasJoinedServer(room.id)) {
|
||||||
|
const selector = this.resolveRoomSignalSelector(primarySource, resolvedRoom.name);
|
||||||
|
const authoritativeServer = (
|
||||||
|
selector
|
||||||
|
? await firstValueFrom(this.serverDirectory.getServer(room.id, selector))
|
||||||
|
: null
|
||||||
|
) ?? await firstValueFrom(this.serverDirectory.findServerAcrossActiveEndpoints(room.id, primarySource));
|
||||||
|
|
||||||
|
if (authoritativeServer) {
|
||||||
|
const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource({
|
||||||
|
sourceId: authoritativeServer.sourceId ?? primarySource.sourceId,
|
||||||
|
sourceName: authoritativeServer.sourceName ?? primarySource.sourceName,
|
||||||
|
sourceUrl: authoritativeServer.sourceUrl ?? primarySource.sourceUrl,
|
||||||
|
fallbackName: authoritativeServer.sourceName ?? primarySource.sourceName ?? resolvedRoom.name
|
||||||
|
}, {
|
||||||
|
ensureEndpoint: !!(authoritativeServer.sourceUrl ?? primarySource.sourceUrl)
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvedRoom = this.repairRoomSignalSource(resolvedRoom, authoritativeSource);
|
||||||
|
primarySource = authoritativeSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSources = this.serverDirectory.getFallbackRoomEndpoints(primarySource)
|
||||||
|
.map((endpoint) => this.serverDirectory.normaliseRoomSignalSource({
|
||||||
|
sourceId: endpoint.id,
|
||||||
|
sourceName: endpoint.name,
|
||||||
|
sourceUrl: endpoint.url,
|
||||||
|
fallbackName: endpoint.name
|
||||||
|
}))
|
||||||
|
.filter((source, index, sources) =>
|
||||||
|
sources.findIndex((candidate) => areRoomSignalSourcesEqual(candidate, source)) === index
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fallbackSources,
|
||||||
|
primarySource: this.resolveRoomSignalSelector(primarySource, resolvedRoom.name) ? primarySource : null,
|
||||||
|
room: resolvedRoom
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectRoomToSignalSource(
|
||||||
|
room: Room,
|
||||||
|
source: RoomSignalSource,
|
||||||
|
user: User | null,
|
||||||
|
resolvedOderId: string | undefined,
|
||||||
|
savedRooms: Room[],
|
||||||
|
navigationRequestVersion?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const selector = this.resolveRoomSignalSelector(source, room.name);
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
||||||
|
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 = () => {
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.identify(oderId, displayName, wsUrl);
|
||||||
|
|
||||||
|
for (const backgroundRoom of backgroundRooms) {
|
||||||
|
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 true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connected = await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
|
||||||
|
|
||||||
|
if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
joinCurrentEndpointRooms();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private repairRoomSignalSource(room: Room, source: RoomSignalSource | null): Room {
|
||||||
|
if (!source || areRoomSignalSourcesEqual(room, source)) {
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: Partial<Room> = {
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
sourceName: source.sourceName,
|
||||||
|
sourceUrl: source.sourceUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
...changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPreferredRoomSignalSource(room: Room): RoomSignalSource {
|
||||||
|
const fallbackSource = this.roomSignalFallbackSources.get(room.id);
|
||||||
|
|
||||||
|
if (fallbackSource && this.webrtc.hasJoinedServer(room.id)) {
|
||||||
|
return fallbackSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveRoomSignalSource(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.resolveRoomSignalingUrl(room) !== wsUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenRoomIds.add(room.id);
|
||||||
|
matchingRooms.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchingRooms;
|
||||||
|
}
|
||||||
|
}
|
||||||
814
toju-app/src/app/store/rooms/room-state-sync.effects.ts
Normal file
814
toju-app/src/app/store/rooms/room-state-sync.effects.ts
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Actions,
|
||||||
|
createEffect,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
|
import { Action, Store } from '@ngrx/store';
|
||||||
|
import {
|
||||||
|
of,
|
||||||
|
from,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
withLatestFrom,
|
||||||
|
tap,
|
||||||
|
switchMap,
|
||||||
|
catchError
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
import { RoomsActions } from './rooms.actions';
|
||||||
|
import { UsersActions } from '../users/users.actions';
|
||||||
|
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||||
|
import {
|
||||||
|
selectActiveChannelId,
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectSavedRooms
|
||||||
|
} from './rooms.selectors';
|
||||||
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
|
import { resolveRoomPermission } from '../../domains/access-control';
|
||||||
|
import {
|
||||||
|
ChatEvent,
|
||||||
|
Room,
|
||||||
|
RoomSettings,
|
||||||
|
RoomPermissions,
|
||||||
|
BanEntry,
|
||||||
|
User,
|
||||||
|
VoiceState
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
|
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
|
||||||
|
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||||
|
import {
|
||||||
|
buildSignalingUser,
|
||||||
|
buildKnownUserExtras,
|
||||||
|
isWrongServer,
|
||||||
|
resolveRoom,
|
||||||
|
sanitizeRoomSnapshot,
|
||||||
|
normalizeIncomingBans,
|
||||||
|
getPersistedCurrentUserId,
|
||||||
|
RoomPresenceSignalingMessage
|
||||||
|
} from './rooms.helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NgRx effects for real-time state synchronisation: signaling presence
|
||||||
|
* events (server_users, user_joined, user_left, access_denied), P2P
|
||||||
|
* room-state / icon sync, and voice/screen/camera state broadcasts.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RoomStateSyncEffects {
|
||||||
|
private actions$ = inject(Actions);
|
||||||
|
private store = inject(Store);
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
private audioService = inject(NotificationAudioService);
|
||||||
|
private voiceSessionService = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks user IDs we already know are in voice. Lives outside the
|
||||||
|
* NgRx store so it survives room switches and presence re-syncs,
|
||||||
|
* preventing false join/leave sounds during state refreshes.
|
||||||
|
*/
|
||||||
|
private knownVoiceUsers = new Set<string>();
|
||||||
|
/**
|
||||||
|
* When a user leaves (e.g. socket drops), record the timestamp so
|
||||||
|
* that a rapid re-join (reconnect) does not trigger a false
|
||||||
|
* join/leave sound within {@link RECONNECT_SOUND_GRACE_MS}.
|
||||||
|
*/
|
||||||
|
private recentlyLeftVoiceTimestamps = new Map<string, number>();
|
||||||
|
|
||||||
|
// ── Signaling presence ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
message,
|
||||||
|
currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
const signalingMessage: RoomPresenceSignalingMessage = message;
|
||||||
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
|
const viewedServerId = currentRoom?.id;
|
||||||
|
const room = resolveRoom(signalingMessage.serverId, currentRoom, savedRooms);
|
||||||
|
const shouldClearReconnectFlag = !isWrongServer(signalingMessage.serverId, viewedServerId);
|
||||||
|
|
||||||
|
switch (signalingMessage.type) {
|
||||||
|
case 'server_users': {
|
||||||
|
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const syncedUsers = signalingMessage.users
|
||||||
|
.filter((user) => user.oderId !== myId)
|
||||||
|
.map((user) =>
|
||||||
|
buildSignalingUser(user, {
|
||||||
|
...buildKnownUserExtras(room, user.oderId),
|
||||||
|
presenceServerIds: [signalingMessage.serverId]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const actions: Action[] = [
|
||||||
|
UsersActions.syncServerPresence({
|
||||||
|
roomId: signalingMessage.serverId,
|
||||||
|
users: syncedUsers
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (shouldClearReconnectFlag) {
|
||||||
|
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user_joined': {
|
||||||
|
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
if (!signalingMessage.oderId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const joinedUser = {
|
||||||
|
oderId: signalingMessage.oderId,
|
||||||
|
displayName: signalingMessage.displayName
|
||||||
|
};
|
||||||
|
const actions: Action[] = [
|
||||||
|
UsersActions.userJoined({
|
||||||
|
user: buildSignalingUser(joinedUser, {
|
||||||
|
...buildKnownUserExtras(room, joinedUser.oderId),
|
||||||
|
presenceServerIds: [signalingMessage.serverId]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (shouldClearReconnectFlag) {
|
||||||
|
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user_left': {
|
||||||
|
if (!signalingMessage.oderId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
|
||||||
|
? signalingMessage.serverIds
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!remainingServerIds || remainingServerIds.length === 0) {
|
||||||
|
if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
|
||||||
|
this.recentlyLeftVoiceTimestamps.set(signalingMessage.oderId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.knownVoiceUsers.delete(signalingMessage.oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: Action[] = [
|
||||||
|
UsersActions.userLeft({
|
||||||
|
userId: signalingMessage.oderId,
|
||||||
|
serverId: signalingMessage.serverId,
|
||||||
|
serverIds: remainingServerIds
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (shouldClearReconnectFlag) {
|
||||||
|
actions.unshift(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'access_denied': {
|
||||||
|
if (isWrongServer(signalingMessage.serverId, viewedServerId))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
// When multiple signal URLs are configured, the room may already
|
||||||
|
// be successfully joined on a different signal server. Only show
|
||||||
|
// the reconnect notice when the room is not reachable at all.
|
||||||
|
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── P2P state sync ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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),
|
||||||
|
this.store.select(selectActiveChannelId)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
event,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms,
|
||||||
|
allUsers,
|
||||||
|
currentUser,
|
||||||
|
activeChannelId
|
||||||
|
]) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'voice-state':
|
||||||
|
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
|
||||||
|
case 'voice-channel-move':
|
||||||
|
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
|
||||||
|
case 'screen-state':
|
||||||
|
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen');
|
||||||
|
case 'camera-state':
|
||||||
|
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera');
|
||||||
|
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 'channels-update':
|
||||||
|
return this.handleChannelsUpdate(event, currentRoom, savedRooms, activeChannelId);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Voice / Screen / Camera handlers ───────────────────────────
|
||||||
|
|
||||||
|
private handleVoiceOrScreenState(
|
||||||
|
event: ChatEvent,
|
||||||
|
allUsers: User[],
|
||||||
|
currentUser: User | null,
|
||||||
|
kind: 'voice' | 'screen' | 'camera'
|
||||||
|
) {
|
||||||
|
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||||
|
|
||||||
|
if (!userId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||||
|
const userExists = !!existingUser;
|
||||||
|
|
||||||
|
if (kind === 'voice') {
|
||||||
|
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||||
|
|
||||||
|
if (!vs)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
|
||||||
|
? UsersActions.userJoined({
|
||||||
|
user: buildSignalingUser(
|
||||||
|
{ oderId: userId,
|
||||||
|
displayName: event.displayName || existingUser?.displayName || 'User' },
|
||||||
|
{ presenceServerIds: [vs.serverId] }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
// Detect voice-connection transitions to play join/leave sounds.
|
||||||
|
const weAreInVoice = this.webrtc.isVoiceConnected();
|
||||||
|
const nowConnected = vs.isConnected ?? false;
|
||||||
|
const wasKnown = this.knownVoiceUsers.has(userId);
|
||||||
|
const wasInCurrentVoiceRoom = this.isSameVoiceRoom(existingUser?.voiceState, currentUser?.voiceState);
|
||||||
|
const mergedVoiceState = { ...existingUser?.voiceState, ...vs };
|
||||||
|
const isInCurrentVoiceRoom = this.isSameVoiceRoom(mergedVoiceState, currentUser?.voiceState);
|
||||||
|
|
||||||
|
if (weAreInVoice) {
|
||||||
|
const isReconnect = this.consumeRecentLeave(userId);
|
||||||
|
|
||||||
|
if (!isReconnect) {
|
||||||
|
if (((!wasKnown && isInCurrentVoiceRoom) || (userExists && !wasInCurrentVoiceRoom && isInCurrentVoiceRoom)) && nowConnected) {
|
||||||
|
this.audioService.play(AppSound.Joining);
|
||||||
|
} else if (wasInCurrentVoiceRoom && !isInCurrentVoiceRoom) {
|
||||||
|
this.audioService.play(AppSound.Leave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
{
|
||||||
|
presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: Action[] = [];
|
||||||
|
|
||||||
|
if (presenceRefreshAction) {
|
||||||
|
actions.push(presenceRefreshAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push(UsersActions.updateVoiceState({ userId,
|
||||||
|
voiceState: vs }));
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'screen') {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
|
||||||
|
|
||||||
|
if (isCameraEnabled === undefined)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
if (!userExists) {
|
||||||
|
return of(
|
||||||
|
UsersActions.userJoined({
|
||||||
|
user: buildSignalingUser(
|
||||||
|
{ oderId: userId,
|
||||||
|
displayName: event.displayName || 'User' },
|
||||||
|
{ cameraState: { isEnabled: isCameraEnabled } }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(
|
||||||
|
UsersActions.updateCameraState({
|
||||||
|
userId,
|
||||||
|
cameraState: { isEnabled: isCameraEnabled }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVoiceChannelMove(
|
||||||
|
event: ChatEvent,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[],
|
||||||
|
currentUser: User | null
|
||||||
|
) {
|
||||||
|
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
|
||||||
|
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
|
||||||
|
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
|
||||||
|
|
||||||
|
if (!currentUser || !targetUserId || !serverId || !nextVoiceState?.roomId) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId !== currentUser.id && targetUserId !== currentUser.oderId) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = resolveRoom(serverId, currentRoom, savedRooms);
|
||||||
|
const movedChannel = room?.channels?.find((channel) => channel.id === nextVoiceState.roomId && channel.type === 'voice');
|
||||||
|
|
||||||
|
if (!room || !movedChannel) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedVoiceState: Partial<VoiceState> = {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: currentUser.voiceState?.isMuted ?? false,
|
||||||
|
isDeafened: currentUser.voiceState?.isDeafened ?? false,
|
||||||
|
isSpeaking: currentUser.voiceState?.isSpeaking ?? false,
|
||||||
|
isMutedByAdmin: currentUser.voiceState?.isMutedByAdmin,
|
||||||
|
volume: currentUser.voiceState?.volume,
|
||||||
|
roomId: movedChannel.id,
|
||||||
|
serverId: room.id
|
||||||
|
};
|
||||||
|
const wasViewingVoiceServer = this.voiceSessionService.isViewingVoiceServer();
|
||||||
|
|
||||||
|
this.webrtc.startVoiceHeartbeat(movedChannel.id, room.id);
|
||||||
|
this.voiceSessionService.startSession({
|
||||||
|
serverId: room.id,
|
||||||
|
serverName: room.name,
|
||||||
|
roomId: movedChannel.id,
|
||||||
|
roomName: `🔊 ${movedChannel.name}`,
|
||||||
|
serverIcon: room.icon,
|
||||||
|
serverDescription: room.description,
|
||||||
|
serverRoute: `/room/${room.id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
this.voiceSessionService.setViewingVoiceServer(wasViewingVoiceServer);
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'voice-state',
|
||||||
|
oderId: currentUser.oderId || currentUser.id,
|
||||||
|
displayName: currentUser.displayName || 'User',
|
||||||
|
voiceState: updatedVoiceState
|
||||||
|
});
|
||||||
|
|
||||||
|
return of(UsersActions.updateVoiceState({
|
||||||
|
userId: currentUser.id,
|
||||||
|
voiceState: updatedVoiceState
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSameVoiceRoom(
|
||||||
|
voiceState: Partial<VoiceState> | undefined,
|
||||||
|
currentUserVoiceState: Partial<VoiceState> | undefined
|
||||||
|
): boolean {
|
||||||
|
return !!voiceState?.isConnected
|
||||||
|
&& !!currentUserVoiceState?.isConnected
|
||||||
|
&& !!voiceState.roomId
|
||||||
|
&& !!voiceState.serverId
|
||||||
|
&& voiceState.roomId === currentUserVoiceState.roomId
|
||||||
|
&& voiceState.serverId === currentUserVoiceState.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` and cleans up the entry if the given user left
|
||||||
|
* recently enough to be considered a reconnect.
|
||||||
|
*/
|
||||||
|
private consumeRecentLeave(userId: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [id, ts] of this.recentlyLeftVoiceTimestamps) {
|
||||||
|
if (now - ts > RECONNECT_SOUND_GRACE_MS) {
|
||||||
|
this.recentlyLeftVoiceTimestamps.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveTs = this.recentlyLeftVoiceTimestamps.get(userId);
|
||||||
|
|
||||||
|
if (leaveTs !== undefined && now - leaveTs <= RECONNECT_SOUND_GRACE_MS) {
|
||||||
|
this.recentlyLeftVoiceTimestamps.delete(userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server-state sync handlers ─────────────────────────────────
|
||||||
|
|
||||||
|
private handleServerStateRequest(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = 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: 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 = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||||
|
|
||||||
|
if (!room || !incomingRoom)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const roomChanges = sanitizeRoomSnapshot(incomingRoom);
|
||||||
|
const bans = 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,
|
||||||
|
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 = 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 = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||||
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||||
|
|
||||||
|
if (!room || (!permissions && !incomingRoom))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return of(
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: {
|
||||||
|
permissions: permissions
|
||||||
|
? { ...(room.permissions || {}),
|
||||||
|
...permissions } as RoomPermissions
|
||||||
|
: room.permissions,
|
||||||
|
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
|
||||||
|
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
|
||||||
|
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
|
||||||
|
slowModeInterval: typeof incomingRoom?.slowModeInterval === 'number' ? incomingRoom.slowModeInterval : room.slowModeInterval
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleChannelsUpdate(
|
||||||
|
event: ChatEvent,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[],
|
||||||
|
activeChannelId: string
|
||||||
|
): Action[] {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const channels = Array.isArray(event.channels) ? event.channels : null;
|
||||||
|
|
||||||
|
if (!room || !channels) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: Action[] = [
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: { channels }
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!channels.some((channel) => channel.id === activeChannelId)) {
|
||||||
|
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id
|
||||||
|
?? 'general';
|
||||||
|
|
||||||
|
actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Icon sync handlers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private handleIconSummary(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = 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 = 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 = 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((user) => user.id === senderId)),
|
||||||
|
mergeMap((sender) => {
|
||||||
|
if (!sender)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const isOwner = room.hostId === sender.id;
|
||||||
|
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
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]));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
186
toju-app/src/app/store/rooms/rooms.helpers.ts
Normal file
186
toju-app/src/app/store/rooms/rooms.helpers.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
BanEntry,
|
||||||
|
User
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
|
||||||
|
import { findRoomMember } from './room-members.helpers';
|
||||||
|
import { ROOM_URL_PATTERN } from '../../core/constants';
|
||||||
|
|
||||||
|
/** Build a minimal User object from signaling payload. */
|
||||||
|
export 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. */
|
||||||
|
export 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. */
|
||||||
|
export function isWrongServer(
|
||||||
|
msgServerId: string | undefined,
|
||||||
|
viewedServerId: string | undefined
|
||||||
|
): boolean {
|
||||||
|
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUserDisplayName(user: Pick<User, 'displayName' | 'username'> | null | undefined): string {
|
||||||
|
const displayName = user?.displayName?.trim();
|
||||||
|
|
||||||
|
if (displayName) {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user?.username?.trim() || 'User';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPersistedChannels(channels: Room['channels'] | undefined): channels is NonNullable<Room['channels']> {
|
||||||
|
return Array.isArray(channels) && channels.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep cached channels until directory metadata provides a concrete replacement. */
|
||||||
|
export function resolveRoomChannels(
|
||||||
|
cachedChannels: Room['channels'] | undefined,
|
||||||
|
incomingChannels: Room['channels'] | undefined
|
||||||
|
): Room['channels'] | undefined {
|
||||||
|
if (hasPersistedChannels(incomingChannels)) {
|
||||||
|
return incomingChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPersistedChannels(cachedChannels)) {
|
||||||
|
return cachedChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTextChannelId(
|
||||||
|
channels: Room['channels'] | undefined,
|
||||||
|
preferredChannelId?: string | null
|
||||||
|
): string | null {
|
||||||
|
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
|
||||||
|
|
||||||
|
if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) {
|
||||||
|
return preferredChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return textChannels[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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,
|
||||||
|
slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined,
|
||||||
|
permissions: room.permissions ? { ...room.permissions } : undefined,
|
||||||
|
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
||||||
|
members: Array.isArray(room.members) ? room.members : undefined,
|
||||||
|
roles: Array.isArray(room.roles) ? room.roles : undefined,
|
||||||
|
roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined,
|
||||||
|
channelPermissions: Array.isArray(room.channelPermissions) ? room.channelPermissions : 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||||
|
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageChannelsInRoom(
|
||||||
|
room: Room,
|
||||||
|
currentUser: User,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
currentUserRole = getUserRoleForRoom(room, currentUser, currentRoom)
|
||||||
|
): boolean {
|
||||||
|
return currentUserRole === 'host' || resolveRoomPermission(room, currentUser, 'manageChannels');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersistedCurrentUserId(): string | null {
|
||||||
|
return localStorage.getItem('metoyou_currentUserId');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRoomIdFromUrl(url: string): string | null {
|
||||||
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
|
|
||||||
|
return roomMatch ? roomMatch[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomPresenceSignalingMessage {
|
||||||
|
type: string;
|
||||||
|
reason?: string;
|
||||||
|
serverId?: string;
|
||||||
|
serverIds?: string[];
|
||||||
|
users?: { oderId: string; displayName: string }[];
|
||||||
|
oderId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user