Move toju-app into own its folder
This commit is contained in:
525
toju-app/src/app/store/rooms/room-members-sync.effects.ts
Normal file
525
toju-app/src/app/store/rooms/room-members-sync.effects.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
Actions,
|
||||
createEffect,
|
||||
ofType
|
||||
} from '@ngrx/effects';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { EMPTY } from 'rxjs';
|
||||
import {
|
||||
mergeMap,
|
||||
tap,
|
||||
withLatestFrom
|
||||
} from 'rxjs/operators';
|
||||
import {
|
||||
ChatEvent,
|
||||
Room,
|
||||
RoomMember,
|
||||
User
|
||||
} from '../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
|
||||
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;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** 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');
|
||||
|
||||
return {
|
||||
...roomMemberFromUser(currentUser, Date.now(), role),
|
||||
id: existingMember?.id ?? currentUser.id,
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(),
|
||||
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 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)
|
||||
);
|
||||
}
|
||||
|
||||
return this.createRoomMemberUpdateActions(room, members);
|
||||
}
|
||||
|
||||
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 })
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user