Files
Toju/src/app/store/users/users.effects.ts
2026-03-06 05:21:41 +01:00

272 lines
7.4 KiB
TypeScript

/**
* Users store effects (load, kick, ban, host election, profile persistence).
*/
/* 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,
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { UsersActions } from './users.actions';
import {
selectCurrentUser,
selectCurrentUserId,
selectHostId
} from './users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { BanEntry, User } from '../../core/models/index';
@Injectable()
export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private webrtc = inject(WebRTCService);
// Load current user from storage
/** Loads the persisted current user from the local database on startup. */
loadCurrentUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadCurrentUser),
switchMap(() =>
from(this.db.getCurrentUser()).pipe(
switchMap((user) => {
if (!user) {
return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' }));
}
const sanitizedUser = this.clearStartupVoiceConnection(user);
if (sanitizedUser === user) {
return of(UsersActions.loadCurrentUserSuccess({ user }));
}
return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe(
map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })),
// If persistence fails, still load a sanitized in-memory user to keep UI correct.
catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })))
);
}),
catchError((error) =>
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
)
)
)
)
);
private clearStartupVoiceConnection(user: User): User {
const voiceState = user.voiceState;
if (!voiceState)
return user;
const hasStaleConnectionState =
voiceState.isConnected ||
voiceState.isSpeaking ||
voiceState.roomId !== undefined ||
voiceState.serverId !== undefined;
if (!hasStaleConnectionState)
return user;
return {
...user,
voiceState: {
...voiceState,
isConnected: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
}
};
}
/** Loads all users associated with a specific room from the local database. */
loadRoomUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadRoomUsers),
switchMap(({ roomId }) =>
from(this.db.getUsersByRoom(roomId)).pipe(
map((users) => UsersActions.loadRoomUsersSuccess({ users })),
catchError((error) =>
of(UsersActions.loadRoomUsersFailure({ error: error.message }))
)
)
)
)
);
/** Kicks a user from the room (requires moderator+ role). Broadcasts a kick signal. */
kickUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.kickUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([
{ userId },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom)
return EMPTY;
const canKick =
currentUser.role === 'host' ||
currentUser.role === 'admin' ||
currentUser.role === 'moderator';
if (!canKick)
return EMPTY;
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: currentRoom.id,
kickedBy: currentUser.id
});
return of(UsersActions.kickUserSuccess({ userId }));
})
)
);
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
banUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.banUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([
{ userId, reason, expiresAt },
currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom)
return EMPTY;
const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
if (!canBan)
return EMPTY;
const ban: BanEntry = {
oderId: uuidv4(),
userId,
roomId: currentRoom.id,
bannedBy: currentUser.id,
reason,
expiresAt,
timestamp: Date.now()
};
this.db.saveBan(ban);
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: currentRoom.id,
bannedBy: currentUser.id,
reason
});
return of(UsersActions.banUserSuccess({ userId,
ban }));
})
)
);
/** Removes a ban entry from the local database. */
unbanUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.unbanUser),
switchMap(({ oderId }) =>
from(this.db.removeBan(oderId)).pipe(
map(() => UsersActions.unbanUserSuccess({ oderId })),
catchError(() => EMPTY)
)
)
)
);
/** Loads all active bans for the current room from the local database. */
loadBans$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadBans),
withLatestFrom(this.store.select(selectCurrentRoom)),
switchMap(([, currentRoom]) => {
if (!currentRoom) {
return of(UsersActions.loadBansSuccess({ bans: [] }));
}
return from(this.db.getBansForRoom(currentRoom.id)).pipe(
map((bans) => UsersActions.loadBansSuccess({ bans })),
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
);
})
)
);
/** Elects the current user as host if the previous host leaves. */
handleHostLeave$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.userLeft),
withLatestFrom(
this.store.select(selectHostId),
this.store.select(selectCurrentUserId)
),
mergeMap(([
{ userId },
hostId,
currentUserId
]) =>
userId === hostId && currentUserId
? of(UsersActions.updateHost({ userId: currentUserId }))
: EMPTY
)
)
);
/** Persists user profile changes to the local database whenever the current user is updated. */
persistUser$ = createEffect(
() =>
this.actions$.pipe(
ofType(
UsersActions.setCurrentUser,
UsersActions.loadCurrentUserSuccess,
UsersActions.updateCurrentUser
),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, user]) => {
if (user) {
this.db.saveUser(user);
// Ensure current user ID is persisted when explicitly set
this.db.setCurrentUserId(user.id);
}
})
),
{ dispatch: false }
);
}