import { createReducer, on } from '@ngrx/store'; import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import { User, BanEntry, UserStatus } from '../../shared-kernel'; import { UsersActions } from './users.actions'; function normalizePresenceServerIds(serverIds: readonly string[] | undefined): string[] | undefined { if (!Array.isArray(serverIds)) { return undefined; } const normalized = Array.from(new Set( serverIds.filter((serverId): serverId is string => typeof serverId === 'string' && serverId.trim().length > 0) )); return normalized.length > 0 ? normalized : undefined; } function mergePresenceServerIds( existingServerIds: readonly string[] | undefined, incomingServerIds: readonly string[] | undefined ): string[] | undefined { return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]); } function hasLivePeerTransport(user: User, connectedPeerIds: ReadonlySet): boolean { if (connectedPeerIds.size === 0) { return false; } return connectedPeerIds.has(user.id) || connectedPeerIds.has(user.oderId) || (!!user.peerId && connectedPeerIds.has(user.peerId)); } interface AvatarFields { avatarUrl?: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number; } interface ProfileFields { displayName: string; description?: string; profileUpdatedAt?: number; } function hasOwnProperty(object: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(object, key); } function normalizeProfileUpdatedAt(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } function normalizeDisplayName(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim().replace(/\s+/g, ' '); return normalized || undefined; } function normalizeDescription(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); return normalized || undefined; } function mergeProfileFields( existingValue: Partial | undefined, incomingValue: Partial, preferIncomingFallback = true ): ProfileFields { const existingUpdatedAt = normalizeProfileUpdatedAt(existingValue?.profileUpdatedAt) ?? 0; const incomingUpdatedAt = normalizeProfileUpdatedAt(incomingValue.profileUpdatedAt) ?? 0; const preferIncoming = incomingUpdatedAt === existingUpdatedAt ? preferIncomingFallback : incomingUpdatedAt > existingUpdatedAt; const existingDisplayName = normalizeDisplayName(existingValue?.displayName); const incomingDisplayName = normalizeDisplayName(incomingValue.displayName); const existingDescription = normalizeDescription(existingValue?.description); const incomingHasDescription = hasOwnProperty(incomingValue, 'description'); const incomingDescription = normalizeDescription(incomingValue.description); return { displayName: preferIncoming ? (incomingDisplayName || existingDisplayName || 'User') : (existingDisplayName || incomingDisplayName || 'User'), description: preferIncoming ? (incomingHasDescription ? incomingDescription : existingDescription) : existingDescription, profileUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined }; } function mergeAvatarFields( existingValue: AvatarFields | undefined, incomingValue: AvatarFields, preferIncomingFallback = true ): AvatarFields { const existingUpdatedAt = existingValue?.avatarUpdatedAt ?? 0; const incomingUpdatedAt = incomingValue.avatarUpdatedAt ?? 0; const preferIncoming = incomingUpdatedAt === existingUpdatedAt ? preferIncomingFallback : incomingUpdatedAt > existingUpdatedAt; return { avatarUrl: preferIncoming ? (incomingValue.avatarUrl || existingValue?.avatarUrl) : (existingValue?.avatarUrl || incomingValue.avatarUrl), avatarHash: preferIncoming ? (incomingValue.avatarHash || existingValue?.avatarHash) : (existingValue?.avatarHash || incomingValue.avatarHash), avatarMime: preferIncoming ? (incomingValue.avatarMime || existingValue?.avatarMime) : (existingValue?.avatarMime || incomingValue.avatarMime), avatarUpdatedAt: Math.max(existingUpdatedAt, incomingUpdatedAt) || undefined }; } function buildDisconnectedVoiceState(user: User): User['voiceState'] { if (!user.voiceState) { return undefined; } return { ...user.voiceState, isConnected: false, isMuted: false, isDeafened: false, isSpeaking: false, roomId: undefined, serverId: undefined }; } function buildInactiveScreenShareState(user: User): User['screenShareState'] { if (!user.screenShareState) { return undefined; } return { ...user.screenShareState, isSharing: false, streamId: undefined, sourceId: undefined, sourceName: undefined }; } function buildInactiveCameraState(user: User): User['cameraState'] { if (!user.cameraState) { return undefined; } return { ...user.cameraState, isEnabled: false }; } function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User { const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds); const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true; const status = isOnline ? (incomingUser.status !== 'offline' ? incomingUser.status : (existingUser?.status && existingUser.status !== 'offline' ? existingUser.status : 'online')) : 'offline'; const profileFields = mergeProfileFields(existingUser, incomingUser, true); return { ...existingUser, ...incomingUser, ...profileFields, ...mergeAvatarFields(existingUser, incomingUser, true), presenceServerIds, isOnline, status }; } function buildAvatarUser(existingUser: User | undefined, incomingUser: { id: string; oderId: string; username: string; displayName: string; description?: string; profileUpdatedAt?: number; avatarUrl?: string; avatarHash?: string; avatarMime?: string; avatarUpdatedAt?: number; }): User { const profileFields = mergeProfileFields(existingUser, incomingUser, true); return { ...existingUser, id: incomingUser.id, oderId: incomingUser.oderId, username: incomingUser.username || existingUser?.username || 'user', ...profileFields, status: existingUser?.status ?? 'offline', role: existingUser?.role ?? 'member', joinedAt: existingUser?.joinedAt ?? Date.now(), ...mergeAvatarFields(existingUser, incomingUser, true) }; } function buildPresenceRemovalChanges( user: User, update: { serverId?: string; serverIds?: readonly string[] } ): Partial { const nextPresenceServerIds = update.serverIds !== undefined ? normalizePresenceServerIds(update.serverIds) : normalizePresenceServerIds((user.presenceServerIds ?? []).filter((serverId) => serverId !== update.serverId)); const isOnline = (nextPresenceServerIds?.length ?? 0) > 0; const shouldClearLiveState = !isOnline || (!!user.voiceState?.serverId && !nextPresenceServerIds?.includes(user.voiceState.serverId)); return { presenceServerIds: nextPresenceServerIds, isOnline, status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline', voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState, screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState, cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState }; } export interface UsersState extends EntityState { currentUserId: string | null; hostId: string | null; loading: boolean; error: string | null; bans: BanEntry[]; /** Manual status set by user (e.g. DND). `null` = automatic. */ manualStatus: UserStatus | null; } export const usersAdapter: EntityAdapter = createEntityAdapter({ 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: [], manualStatus: null }); export const usersReducer = createReducer( initialState, on(UsersActions.resetUsersState, () => ({ ...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.updateCurrentUserProfile, (state, { profile }) => { if (!state.currentUserId) return state; return usersAdapter.updateOne( { id: state.currentUserId, changes: mergeProfileFields(state.entities[state.currentUserId], profile, true) }, state ); }), on(UsersActions.updateCurrentUserAvatar, (state, { avatar }) => { if (!state.currentUserId) return state; return usersAdapter.updateOne( { id: state.currentUserId, changes: mergeAvatarFields(state.entities[state.currentUserId], avatar, true) }, 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(buildPresenceAwareUser(state.entities[user.id], user), state) ), on(UsersActions.syncServerPresence, (state, { roomId, users, connectedPeerIds }) => { let nextState = state; const seenUserIds = new Set(); const connectedPeerIdSet = new Set(connectedPeerIds ?? []); for (const user of users) { seenUserIds.add(user.id); nextState = usersAdapter.upsertOne( buildPresenceAwareUser(nextState.entities[user.id], user), nextState ); } const stalePresenceUpdates = Object.values(nextState.entities) .filter((user): user is User => !!user && user.id !== nextState.currentUserId && user.presenceServerIds?.includes(roomId) === true && !seenUserIds.has(user.id) && !hasLivePeerTransport(user, connectedPeerIdSet) ) .map((user) => ({ id: user.id, changes: buildPresenceRemovalChanges(user, { serverId: roomId }) })); return stalePresenceUpdates.length > 0 ? usersAdapter.updateMany(stalePresenceUpdates, nextState) : nextState; }), on(UsersActions.userLeft, (state, { userId, serverId, serverIds }) => { const existingUser = state.entities[userId]; if (!existingUser) { return (!serverId && !serverIds) ? usersAdapter.removeOne(userId, state) : state; } if (!serverId && !serverIds) { return usersAdapter.removeOne(userId, state); } return usersAdapter.updateOne( { id: userId, changes: buildPresenceRemovalChanges(existingUser, { serverId, serverIds }) }, state ); }), on(UsersActions.updateUser, (state, { userId, updates }) => usersAdapter.updateOne( { id: userId, changes: updates }, state ) ), on(UsersActions.upsertRemoteUserAvatar, (state, { user }) => usersAdapter.upsertOne(buildAvatarUser(state.entities[user.id], user), 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 }; const hasRoomId = Object.prototype.hasOwnProperty.call(voiceState, 'roomId'); const hasServerId = Object.prototype.hasOwnProperty.call(voiceState, 'serverId'); 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, roomId: hasRoomId ? voiceState.roomId : prev.roomId, serverId: hasServerId ? 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.updateCameraState, (state, { userId, cameraState }) => { const prev = state.entities[userId]?.cameraState || { isEnabled: false }; return usersAdapter.updateOne( { id: userId, changes: { cameraState: { isEnabled: cameraState.isEnabled ?? prev.isEnabled } } }, 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 } ); }), on(UsersActions.setManualStatus, (state, { status }) => { const manualStatus = status; const effectiveStatus = manualStatus ?? 'online'; if (!state.currentUserId) return { ...state, manualStatus }; return usersAdapter.updateOne( { id: state.currentUserId, changes: { status: effectiveStatus } }, { ...state, manualStatus } ); }), on(UsersActions.updateRemoteUserStatus, (state, { userId, status }) => { const existingUser = state.entities[userId]; if (!existingUser) return state; return usersAdapter.updateOne( { id: userId, changes: { status } }, state ); }) );