Files
Toju/toju-app/src/app/store/users/users.effects.ts
2026-04-02 03:18:37 +02:00

663 lines
20 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 { RoomsActions } from '../rooms/rooms.actions';
import {
selectAllUsers,
selectCurrentUser,
selectCurrentUserId,
selectHostId
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../domains/server-directory';
import {
canManageMember,
resolveLegacyRole,
resolveRoomPermission
} from '../../domains/access-control';
import {
BanEntry,
ChatEvent,
Room,
User
} from '../../shared-kernel';
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
type IncomingModerationExtraAction =
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof UsersActions.kickUserSuccess>
| ReturnType<typeof UsersActions.banUserSuccess>;
type IncomingModerationAction =
| ReturnType<typeof RoomsActions.updateRoom>
| IncomingModerationExtraAction;
@Injectable()
export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
// 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),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ userId, roomId },
currentUser,
currentRoom,
savedRooms
]) => {
if (!currentUser)
return EMPTY;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const canKick = this.canKickInRoom(room, currentUser, currentRoom, userId);
if (!canKick)
return EMPTY;
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
return this.serverDirectory.kickServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to revoke server membership on kick:', error);
return of(void 0);
}),
mergeMap(() => {
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
})
);
})
)
);
/** 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),
this.store.select(selectSavedRooms),
this.store.select(selectAllUsers)
),
mergeMap(([
{ userId, roomId, displayName, reason, expiresAt },
currentUser,
currentRoom,
savedRooms,
allUsers
]) => {
if (!currentUser)
return EMPTY;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
const canBan = this.canBanInRoom(room, currentUser, currentRoom, userId);
if (!canBan)
return EMPTY;
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const targetMember = findRoomMember(room.members ?? [], userId);
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
const ban: BanEntry = {
oderId: uuidv4(),
userId,
roomId: room.id,
bannedBy: currentUser.id,
displayName: displayName || targetUser?.displayName || targetMember?.displayName,
reason,
expiresAt,
timestamp: Date.now()
};
return this.serverDirectory.banServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId,
banId: ban.oderId,
displayName: ban.displayName,
reason,
expiresAt
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to persist server ban:', error);
return of(void 0);
}),
switchMap(() =>
from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
return actions;
}),
catchError(() => EMPTY)
)
)
);
})
)
);
/** Removes a ban entry locally and broadcasts the change to peers in the same room. */
unbanUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.unbanUser),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
switchMap(([
{ roomId, oderId },
currentUser,
currentRoom,
savedRooms
]) => {
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
return EMPTY;
return this.serverDirectory.unbanServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
banId: oderId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to remove server ban:', error);
return of(void 0);
}),
switchMap(() =>
from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
}),
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: [] })))
);
})
)
);
/** Applies incoming moderation events from peers to local persistence and UI state. */
incomingModerationEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
event,
currentUser,
currentRoom,
savedRooms
]) => {
switch (event.type) {
case 'kick':
return this.handleIncomingKick(event, currentUser ?? null, currentRoom, savedRooms);
case 'ban':
return this.handleIncomingBan(event, currentUser ?? null, currentRoom, savedRooms);
case 'unban':
return this.handleIncomingUnban(event, currentRoom, savedRooms);
default:
return EMPTY;
}
})
)
);
/** 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 }
);
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
syncSignalingIdentity$ = createEffect(
() =>
this.actions$.pipe(
ofType(
UsersActions.setCurrentUser,
UsersActions.loadCurrentUserSuccess
),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([, user]) => {
if (!user)
return;
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user));
})
),
{ dispatch: false }
);
private 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;
}
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
const displayName = user.displayName?.trim();
if (displayName) {
return displayName;
}
return user.username?.trim() || 'User';
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
return role === 'host' || resolveRoomPermission(room, currentUser, 'manageBans');
}
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'kickMembers');
}
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'banMembers');
}
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
}
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {
return {
members: removeRoomMember(room.members ?? [], targetUserId, targetUserId)
};
}
private resolveIncomingModerationActions(
room: Room,
targetUserId: string,
currentRoom: Room | null,
extra: IncomingModerationExtraAction[] = []
) {
const actions: IncomingModerationAction[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: this.removeMemberFromRoom(room, targetUserId)
})
];
if (currentRoom?.id === room.id) {
actions.push(...extra);
} else {
actions.push(...extra.filter((action) => action.type === RoomsActions.forgetRoom.type));
}
return actions;
}
private shouldAffectVisibleUsers(room: Room, currentRoom: Room | null): boolean {
return currentRoom?.id === room.id;
}
private canForgetForTarget(
targetUserId: string,
currentUser: User | null
): ReturnType<typeof RoomsActions.forgetRoom> | null {
return this.isCurrentUserTarget(targetUserId, currentUser)
? RoomsActions.forgetRoom({ roomId: '' })
: null;
}
private isCurrentUserTarget(targetUserId: string, currentUser: User | null): boolean {
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
}
private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry {
const payloadBan = event.ban && typeof event.ban === 'object'
? event.ban as Partial<BanEntry>
: null;
return {
oderId: typeof payloadBan?.oderId === 'string' ? payloadBan.oderId : uuidv4(),
userId: typeof payloadBan?.userId === 'string' ? payloadBan.userId : targetUserId,
roomId,
bannedBy:
typeof payloadBan?.bannedBy === 'string'
? payloadBan.bannedBy
: (typeof event.bannedBy === 'string' ? event.bannedBy : 'unknown'),
displayName:
typeof payloadBan?.displayName === 'string'
? payloadBan.displayName
: (typeof event.displayName === 'string' ? event.displayName : undefined),
reason:
typeof payloadBan?.reason === 'string'
? payloadBan.reason
: (typeof event.reason === 'string' ? event.reason : undefined),
expiresAt:
typeof payloadBan?.expiresAt === 'number'
? payloadBan.expiresAt
: (typeof event.expiresAt === 'number' ? event.expiresAt : undefined),
timestamp: typeof payloadBan?.timestamp === 'number' ? payloadBan.timestamp : Date.now()
};
}
private handleIncomingKick(
event: ChatEvent,
currentUser: User | null,
currentRoom: Room | null,
savedRooms: Room[]
) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
if (!room || !targetUserId)
return EMPTY;
const actions = this.resolveIncomingModerationActions(
room,
targetUserId,
currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })]
: [
UsersActions.kickUserSuccess({ userId: targetUserId,
roomId: room.id })
]
);
return actions;
}
private handleIncomingBan(
event: ChatEvent,
currentUser: User | null,
currentRoom: Room | null,
savedRooms: Room[]
) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
if (!room || !targetUserId)
return EMPTY;
const ban = this.buildIncomingBan(event, targetUserId, room.id);
const actions = this.resolveIncomingModerationActions(
room,
targetUserId,
currentRoom,
this.isCurrentUserTarget(targetUserId, currentUser)
? [RoomsActions.forgetRoom({ roomId: room.id })]
: [
UsersActions.banUserSuccess({ userId: targetUserId,
roomId: room.id,
ban })
]
);
return from(this.db.saveBan(ban)).pipe(
mergeMap(() => (actions.length > 0 ? actions : EMPTY)),
catchError(() => EMPTY)
);
}
private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
const banOderId = typeof event.banOderId === 'string'
? event.banOderId
: (typeof event.oderId === 'string' ? event.oderId : '');
if (!room || !banOderId)
return EMPTY;
return from(this.db.removeBan(banOderId)).pipe(
mergeMap(() => (currentRoom?.id === room.id
? of(UsersActions.unbanUserSuccess({ oderId: banOderId }))
: EMPTY)),
catchError(() => EMPTY)
);
}
}