272 lines
7.4 KiB
TypeScript
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 }
|
|
);
|
|
}
|