Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,4 @@
export * from './users.actions';
export * from './users.reducer';
export * from './users.selectors';
export * from './users.effects';

View 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> }>()
}
});

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

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

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