911 lines
32 KiB
TypeScript
911 lines
32 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
|
|
import { Injectable, inject } from '@angular/core';
|
|
import { NavigationEnd, Router } from '@angular/router';
|
|
import {
|
|
Actions,
|
|
createEffect,
|
|
ofType
|
|
} from '@ngrx/effects';
|
|
import { Store } from '@ngrx/store';
|
|
import {
|
|
of,
|
|
from,
|
|
EMPTY,
|
|
merge,
|
|
timer
|
|
} from 'rxjs';
|
|
import {
|
|
map,
|
|
mergeMap,
|
|
catchError,
|
|
withLatestFrom,
|
|
tap,
|
|
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 } from '../users/users.selectors';
|
|
import {
|
|
selectActiveChannelId,
|
|
selectCurrentRoom,
|
|
selectSavedRooms
|
|
} from './rooms.selectors';
|
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
|
import {
|
|
clearLastViewedChatFromStorage,
|
|
DatabaseService,
|
|
loadLastViewedChatFromStorage,
|
|
saveLastViewedChatToStorage
|
|
} from '../../infrastructure/persistence';
|
|
import { setStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
|
import { AppI18nService } from '../../core/i18n';
|
|
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
|
import { hasRoomBanForUser } from '../../domains/access-control';
|
|
import { Room } from '../../shared-kernel';
|
|
import {
|
|
removeRoomMember,
|
|
transferRoomOwnership,
|
|
findRoomMember
|
|
} from './room-members.helpers';
|
|
import { defaultChannels } from './room-channels.defaults';
|
|
import { RoomSignalingConnection } from './room-signaling-connection';
|
|
import {
|
|
resolveRoomChannels,
|
|
resolveTextChannelId,
|
|
extractRoomIdFromUrl
|
|
} from './rooms.helpers';
|
|
|
|
type BlockedRoomAccessAction =
|
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
|
| ReturnType<typeof RoomsActions.joinRoomFailure>;
|
|
|
|
const VIEW_SERVER_LOAD_DELAY_MS = 0;
|
|
|
|
@Injectable()
|
|
export class RoomsEffects {
|
|
private actions$ = inject(Actions);
|
|
private store = inject(Store);
|
|
private router = inject(Router);
|
|
private db = inject(DatabaseService);
|
|
private webrtc = inject(RealtimeSessionFacade);
|
|
private serverDirectory = inject(ServerDirectoryFacade);
|
|
private readonly i18n = inject(AppI18nService);
|
|
|
|
private readonly signalingConnection = new RoomSignalingConnection(
|
|
this.webrtc,
|
|
this.serverDirectory,
|
|
this.store
|
|
);
|
|
|
|
/** 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 })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Opens the routed room after login/refresh once saved rooms are available. */
|
|
syncViewedRoomToRoute$ = createEffect(() =>
|
|
merge(
|
|
this.actions$.pipe(
|
|
ofType(
|
|
RoomsActions.loadRoomsSuccess,
|
|
UsersActions.loadCurrentUserSuccess,
|
|
UsersActions.setCurrentUser
|
|
)
|
|
),
|
|
this.router.events.pipe(
|
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
|
|
)
|
|
).pipe(
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
, currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
if (!currentUser) {
|
|
return EMPTY;
|
|
}
|
|
|
|
const roomId = extractRoomIdFromUrl(this.router.url);
|
|
|
|
if (!roomId || currentRoom?.id === roomId) {
|
|
return EMPTY;
|
|
}
|
|
|
|
const room = savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null;
|
|
|
|
if (!room) {
|
|
return EMPTY;
|
|
}
|
|
|
|
return of(RoomsActions.viewServer({ room }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** 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 })))
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Re-joins saved rooms after the signaling socket reconnects so presence is restored. */
|
|
resyncRoomsOnSignalingReconnect$ = createEffect(
|
|
() =>
|
|
this.webrtc.signalingReconnected$.pipe(
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
tap(([
|
|
, user,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
this.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** 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.signalingConnection.syncSavedRoomConnections(user ?? null, currentRoom, savedRooms, this.router.url);
|
|
})
|
|
),
|
|
{ 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: this.i18n.instant('servers.errors.notLoggedIn') }));
|
|
}
|
|
|
|
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() : '';
|
|
|
|
setStoredCurrentUserId(currentUser.id);
|
|
|
|
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,
|
|
channels: defaultChannels(),
|
|
sourceId: endpoint?.id,
|
|
sourceName: endpoint?.name,
|
|
sourceUrl: endpoint?.url
|
|
};
|
|
|
|
return from(this.db.saveRoom(room)).pipe(
|
|
map(() => {
|
|
// 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: room.userCount,
|
|
maxUsers: room.maxUsers || 50,
|
|
icon: room.icon,
|
|
iconUpdatedAt: room.iconUpdatedAt,
|
|
tags: [],
|
|
channels: room.channels ?? defaultChannels()
|
|
}, endpoint ? {
|
|
sourceId: endpoint.id,
|
|
sourceUrl: endpoint.url
|
|
} : undefined
|
|
)
|
|
.subscribe();
|
|
|
|
return 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: _password, serverInfo }, currentUser]) => {
|
|
if (!currentUser) {
|
|
return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.notLoggedIn') }));
|
|
}
|
|
|
|
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
|
|
? this.serverDirectory.buildRoomSignalSelector({
|
|
sourceId: serverInfo.sourceId,
|
|
sourceName: serverInfo.sourceName,
|
|
sourceUrl: serverInfo.sourceUrl,
|
|
signalingUrl: serverInfo.signalingUrl,
|
|
fallbackName: serverInfo.sourceName ?? serverInfo.name
|
|
}, {
|
|
ensureEndpoint: !!(serverInfo.sourceUrl ?? serverInfo.signalingUrl)
|
|
})
|
|
: undefined;
|
|
|
|
if (room) {
|
|
setStoredCurrentUserId(currentUser.id);
|
|
|
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
|
sourceId: serverInfo?.sourceId ?? room.sourceId,
|
|
sourceName: serverInfo?.sourceName ?? room.sourceName,
|
|
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
|
|
signalingUrl: serverInfo?.signalingUrl,
|
|
fallbackName: serverInfo?.sourceName ?? room.sourceName ?? serverInfo?.name ?? room.name
|
|
}, {
|
|
ensureEndpoint: !!(serverInfo?.sourceUrl ?? room.sourceUrl ?? serverInfo?.signalingUrl)
|
|
});
|
|
const resolvedRoom: Room = {
|
|
...room,
|
|
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
|
|
icon: serverInfo?.icon ?? room.icon,
|
|
iconUpdatedAt: serverInfo?.iconUpdatedAt ?? room.iconUpdatedAt,
|
|
channels: resolveRoomChannels(room.channels, serverInfo?.channels),
|
|
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
|
|
roles: serverInfo?.roles ?? room.roles,
|
|
roleAssignments: serverInfo?.roleAssignments ?? room.roleAssignments,
|
|
channelPermissions: serverInfo?.channelPermissions ?? room.channelPermissions,
|
|
...resolvedSource,
|
|
hasPassword:
|
|
typeof serverInfo?.hasPassword === 'boolean'
|
|
? serverInfo.hasPassword
|
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
|
|
};
|
|
|
|
return from(this.db.updateRoom(room.id, {
|
|
sourceId: resolvedRoom.sourceId,
|
|
sourceName: resolvedRoom.sourceName,
|
|
sourceUrl: resolvedRoom.sourceUrl,
|
|
channels: resolvedRoom.channels,
|
|
slowModeInterval: resolvedRoom.slowModeInterval,
|
|
roles: resolvedRoom.roles,
|
|
roleAssignments: resolvedRoom.roleAssignments,
|
|
channelPermissions: resolvedRoom.channelPermissions,
|
|
icon: resolvedRoom.icon,
|
|
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
|
|
hasPassword: resolvedRoom.hasPassword,
|
|
isPrivate: resolvedRoom.isPrivate
|
|
})).pipe(map(() => RoomsActions.joinRoomSuccess({ room: resolvedRoom })));
|
|
}
|
|
|
|
// If not in local DB but we have server info from search, create a room entry
|
|
if (serverInfo) {
|
|
setStoredCurrentUserId(currentUser.id);
|
|
|
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
|
sourceId: serverInfo.sourceId,
|
|
sourceName: serverInfo.sourceName,
|
|
sourceUrl: serverInfo.sourceUrl,
|
|
signalingUrl: serverInfo.signalingUrl,
|
|
fallbackName: serverInfo.sourceName ?? serverInfo.name
|
|
}, {
|
|
ensureEndpoint: !!(serverInfo.sourceUrl ?? serverInfo.signalingUrl)
|
|
});
|
|
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,
|
|
icon: serverInfo.icon,
|
|
iconUpdatedAt: serverInfo.iconUpdatedAt,
|
|
channels: resolveRoomChannels(undefined, serverInfo.channels),
|
|
slowModeInterval: serverInfo.slowModeInterval,
|
|
roles: serverInfo.roles,
|
|
roleAssignments: serverInfo.roleAssignments,
|
|
channelPermissions: serverInfo.channelPermissions,
|
|
...resolvedSource
|
|
};
|
|
|
|
return from(this.db.saveRoom(newRoom)).pipe(
|
|
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
|
|
);
|
|
}
|
|
|
|
// Try to get room info from server
|
|
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
|
|
switchMap((serverData) => {
|
|
if (serverData) {
|
|
setStoredCurrentUserId(currentUser.id);
|
|
|
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
|
sourceId: serverData.sourceId,
|
|
sourceName: serverData.sourceName,
|
|
sourceUrl: serverData.sourceUrl,
|
|
fallbackName: serverData.sourceName ?? serverData.name
|
|
}, {
|
|
ensureEndpoint: !!serverData.sourceUrl
|
|
});
|
|
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,
|
|
icon: serverData.icon,
|
|
iconUpdatedAt: serverData.iconUpdatedAt,
|
|
channels: resolveRoomChannels(undefined, serverData.channels),
|
|
slowModeInterval: serverData.slowModeInterval,
|
|
roles: serverData.roles,
|
|
roleAssignments: serverData.roleAssignments,
|
|
channelPermissions: serverData.channelPermissions,
|
|
...resolvedSource
|
|
};
|
|
|
|
return from(this.db.saveRoom(newRoom)).pipe(
|
|
map(() => RoomsActions.joinRoomSuccess({ room: newRoom }))
|
|
);
|
|
}
|
|
|
|
return of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') }));
|
|
}),
|
|
catchError(() => of(RoomsActions.joinRoomFailure({ error: this.i18n.instant('servers.errors.roomNotFound') })))
|
|
);
|
|
}),
|
|
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
|
|
]) => {
|
|
const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id);
|
|
|
|
void this.signalingConnection.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
|
showCompatibilityError: true,
|
|
navigationRequestVersion
|
|
});
|
|
|
|
this.router.navigate(['/room', room.id]);
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Remembers the viewed room whenever a room becomes active. */
|
|
persistLastViewedChatOnRoomActivation$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
tap(([{ room }, currentUser]) => {
|
|
if (!currentUser) {
|
|
return;
|
|
}
|
|
|
|
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
|
const channelId = persisted?.roomId === room.id
|
|
? resolveTextChannelId(room.channels, persisted.channelId)
|
|
: resolveTextChannelId(room.channels);
|
|
|
|
saveLastViewedChatToStorage({
|
|
userId: currentUser.id,
|
|
roomId: room.id,
|
|
channelId
|
|
});
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Remembers the currently selected text channel for the active room. */
|
|
persistLastViewedChatOnChannelSelection$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.selectChannel),
|
|
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
|
|
tap(([
|
|
{ channelId },
|
|
currentRoom,
|
|
currentUser
|
|
]) => {
|
|
if (!currentRoom || !currentUser) {
|
|
return;
|
|
}
|
|
|
|
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
|
|
|
|
if (!resolvedChannelId || resolvedChannelId !== channelId) {
|
|
return;
|
|
}
|
|
|
|
saveLastViewedChatToStorage({
|
|
userId: currentUser.id,
|
|
roomId: currentRoom.id,
|
|
channelId
|
|
});
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Restores the last viewed text channel once the active room's channels are known. */
|
|
restoreLastViewedTextChannel$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(
|
|
RoomsActions.createRoomSuccess,
|
|
RoomsActions.joinRoomSuccess,
|
|
RoomsActions.viewServerSuccess,
|
|
RoomsActions.updateRoom
|
|
),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectActiveChannelId)
|
|
),
|
|
mergeMap(([
|
|
, currentUser,
|
|
currentRoom,
|
|
activeChannelId
|
|
]) => {
|
|
if (!currentUser || !currentRoom) {
|
|
return EMPTY;
|
|
}
|
|
|
|
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
|
|
|
if (!persisted || persisted.roomId !== currentRoom.id) {
|
|
return EMPTY;
|
|
}
|
|
|
|
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
|
|
|
|
if (!channelId || channelId === activeChannelId) {
|
|
return EMPTY;
|
|
}
|
|
|
|
return of(RoomsActions.selectChannel({ channelId }));
|
|
})
|
|
)
|
|
);
|
|
|
|
refreshServerOwnedRoomMetadata$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
|
switchMap(({ room }) => {
|
|
const source = this.signalingConnection.resolveRoomSignalSource(room);
|
|
const selector = this.signalingConnection.resolveRoomSignalSelector(source, room.name);
|
|
const roomRequest$ = selector
|
|
? this.serverDirectory.getServer(room.id, selector).pipe(
|
|
switchMap((serverData) => serverData
|
|
? of(serverData)
|
|
: this.serverDirectory.findServerAcrossActiveEndpoints(room.id, source))
|
|
)
|
|
: this.serverDirectory.findServerAcrossActiveEndpoints(room.id, source);
|
|
|
|
return roomRequest$.pipe(
|
|
map((serverData) => {
|
|
if (!serverData) {
|
|
return null;
|
|
}
|
|
|
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
|
|
sourceId: serverData.sourceId ?? room.sourceId,
|
|
sourceName: serverData.sourceName ?? room.sourceName,
|
|
sourceUrl: serverData.sourceUrl ?? room.sourceUrl,
|
|
fallbackName: serverData.sourceName ?? room.sourceName ?? room.name
|
|
}, {
|
|
ensureEndpoint: !!(serverData.sourceUrl ?? room.sourceUrl)
|
|
});
|
|
|
|
return RoomsActions.updateRoom({
|
|
roomId: room.id,
|
|
changes: {
|
|
name: serverData.name,
|
|
description: serverData.description,
|
|
hostId: serverData.ownerId || room.hostId,
|
|
hasPassword: !!serverData.hasPassword,
|
|
isPrivate: serverData.isPrivate,
|
|
maxUsers: serverData.maxUsers,
|
|
icon: serverData.icon ?? room.icon,
|
|
iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt,
|
|
channels: resolveRoomChannels(room.channels, serverData.channels),
|
|
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
|
|
roles: serverData.roles ?? room.roles,
|
|
roleAssignments: serverData.roleAssignments ?? room.roleAssignments,
|
|
channelPermissions: serverData.channelPermissions ?? room.channelPermissions,
|
|
...resolvedSource
|
|
}
|
|
});
|
|
}),
|
|
filter((action): action is ReturnType<typeof RoomsActions.updateRoom> => !!action),
|
|
catchError(() => EMPTY)
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** 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: this.i18n.instant('servers.errors.notLoggedIn') }));
|
|
}
|
|
|
|
const activateViewedRoom = () => {
|
|
const oderId = user.oderId || this.webrtc.peerId();
|
|
const navigationRequestVersion = this.signalingConnection.beginRoomNavigation(room.id);
|
|
|
|
void this.signalingConnection.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
|
showCompatibilityError: true,
|
|
navigationRequestVersion
|
|
});
|
|
|
|
window.setTimeout(() => {
|
|
if (this.signalingConnection.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
|
void this.router.navigate(['/room', room.id]);
|
|
}
|
|
}, 0);
|
|
|
|
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 bans when the viewed server changes. */
|
|
onViewServerSuccess$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.viewServerSuccess),
|
|
switchMap(({ room }) => VIEW_SERVER_LOAD_DELAY_MS > 0
|
|
? timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
|
|
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
|
)
|
|
: of(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.signalingConnection.deleteRoomFallbackSource(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, {
|
|
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);
|
|
this.signalingConnection.deleteRoomFallbackSource(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 }));
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Clears stale resume state when the remembered room is removed locally. */
|
|
clearLastViewedChatOnRoomRemoval$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.deleteRoomSuccess, RoomsActions.forgetRoomSuccess),
|
|
tap(({ roomId }) => {
|
|
const persisted = loadLastViewedChatFromStorage();
|
|
|
|
if (persisted?.roomId === roomId) {
|
|
clearLastViewedChatFromStorage();
|
|
}
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** 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 viewed messages when leaving a room. */
|
|
onLeaveRoom$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(RoomsActions.leaveRoomSuccess),
|
|
mergeMap(() => [MessagesActions.clearMessages()])
|
|
)
|
|
);
|
|
|
|
/** 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 }
|
|
);
|
|
|
|
// ── Private helpers ────────────────────────────────────────────
|
|
|
|
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: this.i18n.instant('servers.errors.banned') })];
|
|
const storedRoom = await this.db.getRoom(roomId);
|
|
|
|
if (storedRoom) {
|
|
blockedActions.unshift(RoomsActions.forgetRoom({ roomId }));
|
|
}
|
|
|
|
return blockedActions;
|
|
}
|
|
|
|
private getPersistedCurrentUserId(): string | null {
|
|
return localStorage.getItem('metoyou_currentUserId');
|
|
}
|
|
}
|