/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store, type Action } from '@ngrx/store'; import { EMPTY } from 'rxjs'; import { mergeMap, tap, withLatestFrom } from 'rxjs/operators'; import type { ChatEvent, Room, RoomMember, User } from '../../shared-kernel'; import { RealtimeSessionFacade } from '../../core/realtime'; import { UsersActions } from '../users/users.actions'; import { selectAllUsers, selectCurrentUser } from '../users/users.selectors'; import { RoomsActions } from './rooms.actions'; import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control'; import { areRoomMembersEqual, findRoomMember, mergeRoomMembers, pruneRoomMembers, removeRoomMember, roomMemberFromUser, touchRoomMemberLastSeen, transferRoomOwnership, updateRoomMemberRole, upsertRoomMember } from './room-members.helpers'; @Injectable() export class RoomMembersSyncEffects { private readonly actions$ = inject(Actions); private readonly store = inject(Store); private readonly webrtc = inject(RealtimeSessionFacade); /** Ensure the local user is recorded in a room as soon as it becomes active. */ ensureCurrentMemberOnRoomEntry$ = createEffect(() => this.actions$.pipe( ofType( RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess ), withLatestFrom(this.store.select(selectCurrentUser)), mergeMap(([{ room }, currentUser]) => { if (!currentUser) return EMPTY; const members = upsertRoomMember( room.members ?? [], this.buildCurrentUserMember(room, currentUser, true) ); const actions = this.createRoomMemberUpdateActions(room, members); return actions.length > 0 ? actions : EMPTY; }) ) ); /** Keep the viewed room's local member record aligned with the current profile. */ syncCurrentUserIntoCurrentRoom$ = createEffect(() => this.actions$.pipe( ofType( UsersActions.loadCurrentUserSuccess, UsersActions.setCurrentUser, UsersActions.updateCurrentUser ), withLatestFrom( this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom) ), mergeMap(([ , currentUser, currentRoom ]) => { if (!currentUser || !currentRoom) return EMPTY; const members = upsertRoomMember( currentRoom.members ?? [], this.buildCurrentUserMember(currentRoom, currentUser, true) ); const actions = this.createRoomMemberUpdateActions(currentRoom, members); return actions.length > 0 ? actions : EMPTY; }) ) ); /** Persist room-role changes into the stored member roster for the active room. */ syncRoleChangesIntoCurrentRoom$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.updateUserRole), withLatestFrom(this.store.select(selectCurrentRoom)), mergeMap(([{ userId, role }, currentRoom]) => { if (!currentRoom) return EMPTY; const members = updateRoomMemberRole(currentRoom.members ?? [], userId, role); const actions = this.createRoomMemberUpdateActions(currentRoom, members); return actions.length > 0 ? actions : EMPTY; }) ) ); /** Keep active-room user roles derived from room access-control assignments. */ syncAccessControlRolesIntoUsers$ = createEffect(() => this.actions$.pipe( ofType( RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess, RoomsActions.updateRoom ), withLatestFrom( this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms), this.store.select(selectAllUsers), this.store.select(selectCurrentUser) ), mergeMap(([ action, currentRoom, savedRooms, allUsers, currentUser ]) => { const room = this.resolveRoleSyncRoom(action, currentRoom, savedRooms); if (!room) return EMPTY; const actions = this.createUserRoleSyncActions(room, allUsers, currentUser ?? null); return actions.length > 0 ? actions : EMPTY; }) ) ); /** Update persisted room rosters when signaling presence changes arrive. */ signalingPresenceIntoRoomMembers$ = createEffect(() => this.webrtc.onSignalingMessage.pipe( withLatestFrom( this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms), this.store.select(selectCurrentUser) ), mergeMap(([ message, currentRoom, savedRooms, currentUser ]) => { const signalingMessage: { type: string; serverId?: string; users?: { oderId: string; displayName: string }[]; oderId?: string; displayName?: string; } = message; const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room) return EMPTY; const myId = currentUser?.oderId || currentUser?.id; switch (signalingMessage.type) { case 'server_users': { if (!Array.isArray(signalingMessage.users)) return EMPTY; let members = room.members ?? []; for (const user of signalingMessage.users as { oderId: string; displayName: string }[]) { if (!user?.oderId || user.oderId === myId) continue; members = upsertRoomMember(members, this.buildPresenceMember(room, user)); } const actions = this.createRoomMemberUpdateActions(room, members); return actions.length > 0 ? actions : EMPTY; } case 'user_joined': { if (!signalingMessage.oderId || signalingMessage.oderId === myId) return EMPTY; const joinedUser = { oderId: signalingMessage.oderId, displayName: signalingMessage.displayName }; const members = upsertRoomMember( room.members ?? [], this.buildPresenceMember(room, joinedUser) ); const actions = this.createRoomMemberUpdateActions(room, members); return actions.length > 0 ? actions : EMPTY; } case 'user_left': { if (!signalingMessage.oderId) return EMPTY; const members = touchRoomMemberLastSeen(room.members ?? [], signalingMessage.oderId, Date.now()); const actions = this.createRoomMemberUpdateActions(room, members); return actions.length > 0 ? actions : EMPTY; } default: return EMPTY; } }) ) ); /** Request the latest member roster whenever a new peer data channel opens. */ peerConnectedRosterSync$ = createEffect( () => this.webrtc.onPeerConnected.pipe( withLatestFrom(this.store.select(selectCurrentRoom)), tap(([peerId, currentRoom]) => { if (!currentRoom) return; this.webrtc.sendToPeer(peerId, { type: 'member-roster-request', roomId: currentRoom.id }); }) ), { dispatch: false } ); /** Kick off room-member sync when entering or switching to a room. */ roomEntryRosterSync$ = createEffect( () => this.actions$.pipe( ofType( RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess ), tap(({ room }) => { for (const peerId of this.webrtc.getConnectedPeers()) { try { this.webrtc.sendToPeer(peerId, { type: 'member-roster-request', roomId: room.id }); } catch { /* peer may have disconnected */ } } }) ), { dispatch: false } ); /** Handle peer-to-peer member roster sync and explicit leave messages. */ incomingRoomMemberEvents$ = createEffect(() => this.webrtc.onMessageReceived.pipe( withLatestFrom( this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms), this.store.select(selectCurrentUser) ), mergeMap(([ event, currentRoom, savedRooms, currentUser ]) => { switch (event.type) { case 'member-roster-request': { const actions = this.handleMemberRosterRequest(event, currentRoom, savedRooms, currentUser ?? null); return actions.length > 0 ? actions : EMPTY; } case 'member-roster': { const actions = this.handleMemberRoster(event, currentRoom, savedRooms, currentUser ?? null); return actions.length > 0 ? actions : EMPTY; } case 'member-leave': { const actions = this.handleMemberLeave(event, currentRoom, savedRooms); return actions.length > 0 ? actions : EMPTY; } case 'host-change': { const actions = this.handleIncomingHostChange(event, currentRoom, savedRooms, currentUser ?? null); return actions.length > 0 ? actions : EMPTY; } case 'role-change': { const actions = this.handleIncomingRoleChange(event, currentRoom, savedRooms); return actions.length > 0 ? actions : EMPTY; } default: return EMPTY; } }) ) ); private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { if (!roomId) return null; if (currentRoom?.id === roomId) return currentRoom; return savedRooms.find((room) => room.id === roomId) ?? null; } private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember { const existingMember = findRoomMember(room.members ?? [], currentUser.oderId || currentUser.id); const role = room.hostId === currentUser.id ? 'host' : (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member'); const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now(); return { ...roomMemberFromUser(currentUser, seenAt, role), id: existingMember?.id ?? currentUser.id, joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt, avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl, role }; } private buildPresenceMember( room: Room, data: { oderId: string; displayName?: string } ): RoomMember { const existingMember = findRoomMember(room.members ?? [], data.oderId); const now = Date.now(); return { id: existingMember?.id ?? data.oderId, oderId: data.oderId, username: existingMember?.username ?? (data.displayName || 'User').toLowerCase().replace(/\s+/g, '_'), displayName: data.displayName || existingMember?.displayName || 'User', avatarUrl: existingMember?.avatarUrl, role: existingMember?.role ?? 'member', joinedAt: existingMember?.joinedAt ?? now, lastSeenAt: now }; } private createRoomMemberUpdateActions(room: Room, members: RoomMember[]): Action[] { return areRoomMembersEqual(room.members ?? [], members) ? [] : [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })]; } private resolveRoleSyncRoom( action: | ReturnType | ReturnType | ReturnType | ReturnType, currentRoom: Room | null, savedRooms: Room[] ): Room | null { if ('room' in action) { return normalizeRoomAccessControl(action.room); } if (currentRoom?.id !== action.roomId || !this.hasRoleRelevantRoomChanges(action.changes)) { return null; } const room = this.resolveRoom(action.roomId, currentRoom, savedRooms); return room ? normalizeRoomAccessControl({ ...room, ...action.changes }) : null; } private hasRoleRelevantRoomChanges(changes: Partial): boolean { return ( Object.prototype.hasOwnProperty.call(changes, 'hostId') || Object.prototype.hasOwnProperty.call(changes, 'members') || Object.prototype.hasOwnProperty.call(changes, 'permissions') || Object.prototype.hasOwnProperty.call(changes, 'roles') || Object.prototype.hasOwnProperty.call(changes, 'roleAssignments') || Object.prototype.hasOwnProperty.call(changes, 'channelPermissions') || Object.prototype.hasOwnProperty.call(changes, 'slowModeInterval') ); } private createUserRoleSyncActions(room: Room, allUsers: User[], currentUser: User | null): Action[] { const usersById = new Map(); for (const user of allUsers) { if (this.shouldSyncUserRoleForRoom(room, user, currentUser)) { usersById.set(user.id, user); } } if (currentUser) { usersById.set(currentUser.id, currentUser); } return Array.from(usersById.values()) .map((user) => ({ user, role: resolveLegacyRole(room, user) })) .filter(({ user, role }) => user.role !== role) .map(({ user, role }) => UsersActions.updateUserRole({ userId: user.id, role })); } private shouldSyncUserRoleForRoom(room: Room, user: User, currentUser: User | null): boolean { if (currentUser && user.id === currentUser.id) { return true; } if (room.hostId === user.id || room.hostId === user.oderId) { return true; } if (Array.isArray(user.presenceServerIds) && user.presenceServerIds.includes(room.id)) { return true; } if (findRoomMember(room.members ?? [], user.oderId || user.id)) { return true; } return (room.roleAssignments ?? []).some((assignment) => ( assignment.userId === user.id || assignment.userId === user.oderId || assignment.oderId === user.id || assignment.oderId === user.oderId )); } private handleMemberRosterRequest( event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null ): Action[] { const room = this.resolveRoom(event.roomId, currentRoom, savedRooms); if (!room || !event.fromPeerId) return []; const isCurrentRoom = currentRoom?.id === room.id; let members = room.members ?? []; if (currentUser) { members = upsertRoomMember( members, this.buildCurrentUserMember(room, currentUser, isCurrentRoom) ); } this.webrtc.sendToPeer(event.fromPeerId, { type: 'member-roster', roomId: room.id, members }); return this.createRoomMemberUpdateActions(room, members); } private handleMemberRoster( event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null ): Action[] { const room = this.resolveRoom(event.roomId, currentRoom, savedRooms); if (!room || !Array.isArray(event.members)) return []; let members = mergeRoomMembers(room.members ?? [], event.members); if (currentUser) { members = upsertRoomMember( members, this.buildCurrentUserMember(room, currentUser, currentRoom?.id === room.id) ); } const actions = this.createRoomMemberUpdateActions(room, members); const currentUserId = currentUser?.oderId || currentUser?.id; for (const member of members) { const memberId = member.oderId || member.id; if (!member.avatarUrl || !memberId || memberId === currentUserId) { continue; } actions.push(UsersActions.upsertRemoteUserAvatar({ user: { id: member.id, oderId: memberId, username: member.username, displayName: member.displayName, avatarUrl: member.avatarUrl } })); } return actions; } private handleMemberLeave( event: ChatEvent, currentRoom: Room | null, savedRooms: Room[] ): Action[] { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room) return []; const actions = this.createRoomMemberUpdateActions( room, removeRoomMember(room.members ?? [], event.targetUserId, event.oderId) ); const departedUserId = event.oderId ?? event.targetUserId; if (currentRoom?.id === room.id && departedUserId) { actions.push( UsersActions.userLeft({ userId: departedUserId, serverId: room.id }) ); } return actions; } private handleIncomingHostChange( event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null ): Action[] { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room) return []; const members = Array.isArray(event.members) ? pruneRoomMembers(event.members) : transferRoomOwnership( room.members ?? [], event.hostId || event.hostOderId ? { id: event.hostId, oderId: event.hostOderId } : null, { id: event.previousHostId ?? event.previousHostOderId ?? '', oderId: event.previousHostOderId } ); const hostId = typeof event.hostId === 'string' ? event.hostId : ''; const actions: Action[] = [ RoomsActions.updateRoom({ roomId: room.id, changes: { hostId, members } }) ]; for (const previousHostKey of new Set([event.previousHostId, event.previousHostOderId].filter((value): value is string => !!value))) { actions.push( UsersActions.updateUserRole({ userId: previousHostKey, role: 'member' }) ); } for (const nextHostKey of new Set([event.hostId, event.hostOderId].filter((value): value is string => !!value))) { actions.push( UsersActions.updateUserRole({ userId: nextHostKey, role: 'host' }) ); } if (currentUser) { const isCurrentUserNextHost = event.hostId === currentUser.id || event.hostOderId === currentUser.oderId; const isCurrentUserPreviousHost = event.previousHostId === currentUser.id || event.previousHostOderId === currentUser.oderId; if (isCurrentUserPreviousHost && !isCurrentUserNextHost) { actions.push(UsersActions.updateCurrentUser({ updates: { role: 'member' } })); } if (isCurrentUserNextHost) { actions.push(UsersActions.updateCurrentUser({ updates: { role: 'host' } })); } } return actions; } private handleIncomingRoleChange( event: ChatEvent, currentRoom: Room | null, savedRooms: Room[] ): Action[] { const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id; const room = this.resolveRoom(roomId, currentRoom, savedRooms); if (!room || !event.targetUserId || !event.role) return []; const actions = this.createRoomMemberUpdateActions( room, updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role) ); if (currentRoom?.id === room.id) { actions.push( UsersActions.updateUserRole({ userId: event.targetUserId, role: event.role }) ); } return actions; } }