Move toju-app into own its folder
This commit is contained in:
4
toju-app/src/app/store/users/index.ts
Normal file
4
toju-app/src/app/store/users/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './users.actions';
|
||||
export * from './users.reducer';
|
||||
export * from './users.selectors';
|
||||
export * from './users.effects';
|
||||
57
toju-app/src/app/store/users/users.actions.ts
Normal file
57
toju-app/src/app/store/users/users.actions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Users store actions using `createActionGroup`.
|
||||
*/
|
||||
import {
|
||||
createActionGroup,
|
||||
emptyProps,
|
||||
props
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
User,
|
||||
BanEntry,
|
||||
VoiceState,
|
||||
ScreenShareState
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export const UsersActions = createActionGroup({
|
||||
source: 'Users',
|
||||
events: {
|
||||
'Load Current User': emptyProps(),
|
||||
'Load Current User Success': props<{ user: User }>(),
|
||||
'Load Current User Failure': props<{ error: string }>(),
|
||||
|
||||
'Set Current User': props<{ user: User }>(),
|
||||
'Update Current User': props<{ updates: Partial<User> }>(),
|
||||
|
||||
'Load Room Users': props<{ roomId: string }>(),
|
||||
'Load Room Users Success': props<{ users: User[] }>(),
|
||||
'Load Room Users Failure': props<{ error: string }>(),
|
||||
|
||||
'User Joined': props<{ user: User }>(),
|
||||
'User Left': props<{ userId: string }>(),
|
||||
|
||||
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
||||
|
||||
'Kick User': props<{ userId: string; roomId?: string }>(),
|
||||
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
||||
|
||||
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
|
||||
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
||||
'Unban User': props<{ roomId: string; oderId: string }>(),
|
||||
'Unban User Success': props<{ oderId: string }>(),
|
||||
|
||||
'Load Bans': emptyProps(),
|
||||
'Load Bans Success': props<{ bans: BanEntry[] }>(),
|
||||
|
||||
'Admin Mute User': props<{ userId: string }>(),
|
||||
'Admin Unmute User': props<{ userId: string }>(),
|
||||
|
||||
'Sync Users': props<{ users: User[] }>(),
|
||||
'Clear Users': emptyProps(),
|
||||
'Update Host': props<{ userId: string }>(),
|
||||
|
||||
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
||||
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
|
||||
}
|
||||
});
|
||||
669
toju-app/src/app/store/users/users.effects.ts
Normal file
669
toju-app/src/app/store/users/users.effects.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* 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 {
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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' || role === 'admin';
|
||||
}
|
||||
|
||||
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||
|
||||
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||
}
|
||||
|
||||
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||
|
||||
return role === 'host' || role === 'admin';
|
||||
}
|
||||
|
||||
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||
return (
|
||||
room.hostId === currentUser.id || room.hostId === currentUser.oderId
|
||||
)
|
||||
? 'host'
|
||||
: (currentRoom?.id === room.id
|
||||
? currentUser.role
|
||||
: (findRoomMember(room.members ?? [], currentUser.id)?.role
|
||||
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
||||
|| null));
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
250
toju-app/src/app/store/users/users.reducer.ts
Normal file
250
toju-app/src/app/store/users/users.reducer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import {
|
||||
EntityState,
|
||||
EntityAdapter,
|
||||
createEntityAdapter
|
||||
} from '@ngrx/entity';
|
||||
import { User, BanEntry } from '../../shared-kernel';
|
||||
import { UsersActions } from './users.actions';
|
||||
|
||||
export interface UsersState extends EntityState<User> {
|
||||
currentUserId: string | null;
|
||||
hostId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
bans: BanEntry[];
|
||||
}
|
||||
|
||||
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||
selectId: (user) => user.id,
|
||||
sortComparer: (userA, userB) => userA.username.localeCompare(userB.username)
|
||||
});
|
||||
|
||||
export const initialState: UsersState = usersAdapter.getInitialState({
|
||||
currentUserId: null,
|
||||
hostId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
bans: []
|
||||
});
|
||||
|
||||
export const usersReducer = createReducer(
|
||||
initialState,
|
||||
on(UsersActions.loadCurrentUser, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(UsersActions.loadCurrentUserSuccess, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error
|
||||
})),
|
||||
on(UsersActions.setCurrentUser, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id
|
||||
})
|
||||
),
|
||||
on(UsersActions.updateCurrentUser, (state, { updates }) => {
|
||||
if (!state.currentUserId)
|
||||
return state;
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: state.currentUserId,
|
||||
changes: updates
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.loadRoomUsers, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(UsersActions.loadRoomUsersSuccess, (state, { users }) =>
|
||||
usersAdapter.upsertMany(users, {
|
||||
...state,
|
||||
loading: false
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error
|
||||
})),
|
||||
on(UsersActions.userJoined, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, state)
|
||||
),
|
||||
on(UsersActions.userLeft, (state, { userId }) =>
|
||||
usersAdapter.removeOne(userId, state)
|
||||
),
|
||||
on(UsersActions.updateUser, (state, { userId, updates }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: updates
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateUserRole, (state, { userId, role }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role }
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.kickUserSuccess, (state, { userId }) =>
|
||||
usersAdapter.removeOne(userId, state)
|
||||
),
|
||||
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
|
||||
const newState = usersAdapter.removeOne(userId, state);
|
||||
|
||||
return {
|
||||
...newState,
|
||||
bans: [...state.bans, ban]
|
||||
};
|
||||
}),
|
||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||
...state,
|
||||
bans: state.bans.filter((ban) => ban.oderId !== oderId)
|
||||
})),
|
||||
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
|
||||
...state,
|
||||
bans
|
||||
})),
|
||||
on(UsersActions.adminMuteUser, (state, { userId }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
...state.entities[userId]?.voiceState,
|
||||
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
|
||||
isMuted: true,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: false,
|
||||
isMutedByAdmin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.adminUnmuteUser, (state, { userId }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
...state.entities[userId]?.voiceState,
|
||||
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
|
||||
isMuted: state.entities[userId]?.voiceState?.isMuted ?? false,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false,
|
||||
isMutedByAdmin: false
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
||||
const prev = state.entities[userId]?.voiceState || {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
isConnected: voiceState.isConnected ?? prev.isConnected,
|
||||
isMuted: voiceState.isMuted ?? prev.isMuted,
|
||||
isDeafened: voiceState.isDeafened ?? prev.isDeafened,
|
||||
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
// Use explicit undefined check - if undefined is passed, clear the value
|
||||
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId,
|
||||
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
||||
const prev = state.entities[userId]?.screenShareState || {
|
||||
isSharing: false
|
||||
};
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
screenShareState: {
|
||||
isSharing: screenShareState.isSharing ?? prev.isSharing,
|
||||
streamId: screenShareState.streamId ?? prev.streamId,
|
||||
sourceId: screenShareState.sourceId ?? prev.sourceId,
|
||||
sourceName: screenShareState.sourceName ?? prev.sourceName
|
||||
}
|
||||
}
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
on(UsersActions.syncUsers, (state, { users }) =>
|
||||
usersAdapter.upsertMany(users, state)
|
||||
),
|
||||
on(UsersActions.clearUsers, (state) => {
|
||||
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
|
||||
|
||||
return usersAdapter.removeMany(idsToRemove, {
|
||||
...state,
|
||||
hostId: null
|
||||
});
|
||||
}),
|
||||
on(UsersActions.updateHost, (state, { userId }) => {
|
||||
let newState = state;
|
||||
|
||||
if (state.hostId && state.hostId !== userId) {
|
||||
newState = usersAdapter.updateOne(
|
||||
{
|
||||
id: state.hostId,
|
||||
changes: { role: 'member' }
|
||||
},
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role: 'host' }
|
||||
},
|
||||
{
|
||||
...newState,
|
||||
hostId: userId
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
104
toju-app/src/app/store/users/users.selectors.ts
Normal file
104
toju-app/src/app/store/users/users.selectors.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { UsersState, usersAdapter } from './users.reducer';
|
||||
|
||||
/** Selects the top-level users feature state. */
|
||||
export const selectUsersState = createFeatureSelector<UsersState>('users');
|
||||
|
||||
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
|
||||
|
||||
/** Selects all user entities as a flat array. */
|
||||
export const selectAllUsers = createSelector(selectUsersState, selectAll);
|
||||
|
||||
/** Selects the user entity dictionary keyed by ID. */
|
||||
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
|
||||
|
||||
/** Selects all user IDs. */
|
||||
export const selectUsersIds = createSelector(selectUsersState, selectIds);
|
||||
|
||||
/** Selects the total count of users. */
|
||||
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
|
||||
|
||||
/** Whether a user-loading operation is in progress. */
|
||||
export const selectUsersLoading = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
/** Selects the most recent users-related error message. */
|
||||
export const selectUsersError = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
/** Selects the current (local) user's ID, or null. */
|
||||
export const selectCurrentUserId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.currentUserId
|
||||
);
|
||||
|
||||
/** Selects the host's user ID. */
|
||||
export const selectHostId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.hostId
|
||||
);
|
||||
|
||||
/** Selects all active ban entries for the current room. */
|
||||
export const selectBannedUsers = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.bans
|
||||
);
|
||||
|
||||
/** Selects the full User entity for the current (local) user. */
|
||||
export const selectCurrentUser = createSelector(
|
||||
selectUsersEntities,
|
||||
selectCurrentUserId,
|
||||
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
|
||||
);
|
||||
|
||||
/** Selects the full User entity for the room host. */
|
||||
export const selectHost = createSelector(
|
||||
selectUsersEntities,
|
||||
selectHostId,
|
||||
(entities, hostId) => (hostId ? entities[hostId] : null)
|
||||
);
|
||||
|
||||
/** Creates a selector that returns a single user by their ID. */
|
||||
export const selectUserById = (id: string) =>
|
||||
createSelector(selectUsersEntities, (entities) => entities[id]);
|
||||
|
||||
/** Whether the current user is the room host. */
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
selectCurrentUserId,
|
||||
selectHostId,
|
||||
(currentUserId, hostId) => currentUserId === hostId
|
||||
);
|
||||
|
||||
/** Whether the current user holds an elevated role (host, admin, or moderator). */
|
||||
export const selectIsCurrentUserAdmin = createSelector(
|
||||
selectCurrentUser,
|
||||
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
|
||||
);
|
||||
|
||||
/** Selects users who are currently online (not offline). */
|
||||
export const selectOnlineUsers = createSelector(
|
||||
selectAllUsers,
|
||||
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true)
|
||||
);
|
||||
|
||||
/** Creates a selector that returns users with a specific role. */
|
||||
export const selectUsersByRole = (role: string) =>
|
||||
createSelector(selectAllUsers, (users) =>
|
||||
users.filter((user) => user.role === role)
|
||||
);
|
||||
|
||||
/** Selects all users with an elevated role (host, admin, or moderator). */
|
||||
export const selectAdmins = createSelector(
|
||||
selectAllUsers,
|
||||
(users) => users.filter((user) => user.role === 'host' || user.role === 'admin' || user.role === 'moderator')
|
||||
);
|
||||
|
||||
/** Whether the current user is the room owner (host role). */
|
||||
export const selectIsCurrentUserOwner = createSelector(
|
||||
selectCurrentUser,
|
||||
(user) => user?.role === 'host'
|
||||
);
|
||||
Reference in New Issue
Block a user