Files
Toju/toju-app/src/app/store/rooms/rooms.effects.ts

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');
}
}