Move toju-app into own its folder
This commit is contained in:
4
toju-app/src/app/store/rooms/index.ts
Normal file
4
toju-app/src/app/store/rooms/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './rooms.actions';
|
||||
export * from './rooms.reducer';
|
||||
export * from './rooms.selectors';
|
||||
export * from './rooms.effects';
|
||||
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;
|
||||
}
|
||||
}
|
||||
313
toju-app/src/app/store/rooms/room-members.helpers.ts
Normal file
313
toju-app/src/app/store/rooms/room-members.helpers.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { RoomMember, User } from '../../shared-kernel';
|
||||
|
||||
/** Remove members that have not been seen for roughly two months. */
|
||||
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
|
||||
|
||||
function fallbackDisplayName(member: Partial<RoomMember>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<RoomMember>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeMember(member: RoomMember, now = Date.now()): RoomMember {
|
||||
const key = getRoomMemberKey(member);
|
||||
const lastSeenAt =
|
||||
typeof member.lastSeenAt === 'number' && Number.isFinite(member.lastSeenAt)
|
||||
? member.lastSeenAt
|
||||
: typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
|
||||
? member.joinedAt
|
||||
: now;
|
||||
const joinedAt =
|
||||
typeof member.joinedAt === 'number' && Number.isFinite(member.joinedAt)
|
||||
? member.joinedAt
|
||||
: lastSeenAt;
|
||||
|
||||
return {
|
||||
id: member.id || key,
|
||||
oderId: member.oderId || undefined,
|
||||
username: member.username || fallbackUsername(member),
|
||||
displayName: fallbackDisplayName(member),
|
||||
avatarUrl: member.avatarUrl || undefined,
|
||||
role: member.role || 'member',
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
}
|
||||
|
||||
function compareMembers(firstMember: RoomMember, secondMember: RoomMember): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0)
|
||||
return displayNameCompare;
|
||||
|
||||
return getRoomMemberKey(firstMember).localeCompare(getRoomMemberKey(secondMember));
|
||||
}
|
||||
|
||||
function mergeRole(
|
||||
existingRole: RoomMember['role'],
|
||||
incomingRole: RoomMember['role'],
|
||||
preferIncoming: boolean
|
||||
): RoomMember['role'] {
|
||||
if (existingRole === incomingRole)
|
||||
return existingRole;
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member')
|
||||
return existingRole;
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member')
|
||||
return incomingRole;
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function mergeMembers(
|
||||
existingMember: RoomMember | undefined,
|
||||
incomingMember: RoomMember,
|
||||
now = Date.now()
|
||||
): RoomMember {
|
||||
const normalizedIncoming = normalizeMember(incomingMember, now);
|
||||
|
||||
if (!existingMember)
|
||||
return normalizedIncoming;
|
||||
|
||||
const normalizedExisting = normalizeMember(existingMember, now);
|
||||
const preferIncoming = normalizedIncoming.lastSeenAt >= normalizedExisting.lastSeenAt;
|
||||
|
||||
return {
|
||||
id: normalizedExisting.id || normalizedIncoming.id,
|
||||
oderId: normalizedIncoming.oderId || normalizedExisting.oderId,
|
||||
username: preferIncoming
|
||||
? (normalizedIncoming.username || normalizedExisting.username)
|
||||
: (normalizedExisting.username || normalizedIncoming.username),
|
||||
displayName: preferIncoming
|
||||
? (normalizedIncoming.displayName || normalizedExisting.displayName)
|
||||
: (normalizedExisting.displayName || normalizedIncoming.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (normalizedIncoming.avatarUrl || normalizedExisting.avatarUrl)
|
||||
: (normalizedExisting.avatarUrl || normalizedIncoming.avatarUrl),
|
||||
role: mergeRole(normalizedExisting.role, normalizedIncoming.role, preferIncoming),
|
||||
joinedAt: Math.min(normalizedExisting.joinedAt, normalizedIncoming.joinedAt),
|
||||
lastSeenAt: Math.max(normalizedExisting.lastSeenAt, normalizedIncoming.lastSeenAt)
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable member key, preferring `oderId` when available. */
|
||||
export function getRoomMemberKey(member: Pick<RoomMember, 'id' | 'oderId'>): string {
|
||||
return member.oderId || member.id || '';
|
||||
}
|
||||
|
||||
/** Find a room member by either their local ID or their `oderId`. */
|
||||
export function findRoomMember(
|
||||
members: RoomMember[] = [],
|
||||
identifier?: string
|
||||
): RoomMember | undefined {
|
||||
if (!identifier)
|
||||
return undefined;
|
||||
|
||||
return members.find((member) => member.id === identifier || member.oderId === identifier);
|
||||
}
|
||||
|
||||
/** Convert a live `User` into a persisted room-member record. */
|
||||
export function roomMemberFromUser(
|
||||
user: User,
|
||||
seenAt = Date.now(),
|
||||
roleOverride?: RoomMember['role']
|
||||
): RoomMember {
|
||||
return normalizeMember(
|
||||
{
|
||||
id: user.id || user.oderId,
|
||||
oderId: user.oderId || undefined,
|
||||
username: user.username || '',
|
||||
displayName: user.displayName || user.username || 'User',
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: roleOverride || user.role || 'member',
|
||||
joinedAt: user.joinedAt || seenAt,
|
||||
lastSeenAt: seenAt
|
||||
},
|
||||
seenAt
|
||||
);
|
||||
}
|
||||
|
||||
/** Deduplicate, sanitize, sort, and prune stale room members. */
|
||||
export function pruneRoomMembers(
|
||||
members: RoomMember[] = [],
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
const cutoff = now - ROOM_MEMBER_STALE_MS;
|
||||
const deduplicatedMembers = new Map<string, RoomMember>();
|
||||
|
||||
for (const member of members) {
|
||||
const key = getRoomMemberKey(member);
|
||||
|
||||
if (!key)
|
||||
continue;
|
||||
|
||||
const normalizedMember = normalizeMember(member, now);
|
||||
|
||||
if (normalizedMember.lastSeenAt < cutoff)
|
||||
continue;
|
||||
|
||||
deduplicatedMembers.set(
|
||||
key,
|
||||
mergeMembers(deduplicatedMembers.get(key), normalizedMember, now)
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(deduplicatedMembers.values()).sort(compareMembers);
|
||||
}
|
||||
|
||||
/** Upsert a member into a room roster while preserving the best known data. */
|
||||
export function upsertRoomMember(
|
||||
members: RoomMember[] = [],
|
||||
member: RoomMember,
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
const key = getRoomMemberKey(member);
|
||||
const nextMembers = pruneRoomMembers(members, now);
|
||||
|
||||
if (!key)
|
||||
return nextMembers;
|
||||
|
||||
const memberIndex = nextMembers.findIndex((entry) => getRoomMemberKey(entry) === key);
|
||||
const mergedMember = mergeMembers(memberIndex >= 0 ? nextMembers[memberIndex] : undefined, member, now);
|
||||
|
||||
if (memberIndex >= 0) {
|
||||
const updatedMembers = [...nextMembers];
|
||||
|
||||
updatedMembers[memberIndex] = mergedMember;
|
||||
return pruneRoomMembers(updatedMembers, now);
|
||||
}
|
||||
|
||||
return pruneRoomMembers([...nextMembers, mergedMember], now);
|
||||
}
|
||||
|
||||
/** Merge a remote roster into the local roster. */
|
||||
export function mergeRoomMembers(
|
||||
localMembers: RoomMember[] = [],
|
||||
incomingMembers: RoomMember[] = [],
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
let mergedMembers = pruneRoomMembers(localMembers, now);
|
||||
|
||||
for (const incomingMember of incomingMembers) {
|
||||
mergedMembers = upsertRoomMember(mergedMembers, incomingMember, now);
|
||||
}
|
||||
|
||||
return pruneRoomMembers(mergedMembers, now);
|
||||
}
|
||||
|
||||
/** Update the last-seen timestamp of a known room member. */
|
||||
export function touchRoomMemberLastSeen(
|
||||
members: RoomMember[] = [],
|
||||
identifier: string,
|
||||
seenAt = Date.now()
|
||||
): RoomMember[] {
|
||||
const nextMembers = pruneRoomMembers(members, seenAt);
|
||||
const memberIndex = nextMembers.findIndex((member) => member.id === identifier || member.oderId === identifier);
|
||||
|
||||
if (memberIndex < 0)
|
||||
return nextMembers;
|
||||
|
||||
const updatedMembers = [...nextMembers];
|
||||
|
||||
updatedMembers[memberIndex] = normalizeMember(
|
||||
{
|
||||
...updatedMembers[memberIndex],
|
||||
lastSeenAt: Math.max(updatedMembers[memberIndex].lastSeenAt, seenAt)
|
||||
},
|
||||
seenAt
|
||||
);
|
||||
|
||||
return pruneRoomMembers(updatedMembers, seenAt);
|
||||
}
|
||||
|
||||
/** Remove a member from a room roster by either ID flavor. */
|
||||
export function removeRoomMember(
|
||||
members: RoomMember[] = [],
|
||||
...identifiers: (string | undefined)[]
|
||||
): RoomMember[] {
|
||||
const ids = new Set(identifiers.filter((identifier): identifier is string => !!identifier));
|
||||
|
||||
if (ids.size === 0)
|
||||
return pruneRoomMembers(members);
|
||||
|
||||
return pruneRoomMembers(members).filter(
|
||||
(member) => !ids.has(member.id) && !ids.has(member.oderId || '')
|
||||
);
|
||||
}
|
||||
|
||||
/** Reassign ownership within a room roster, optionally leaving the room ownerless. */
|
||||
export function transferRoomOwnership(
|
||||
members: RoomMember[] = [],
|
||||
nextOwner: Partial<RoomMember> | null,
|
||||
previousOwner?: Pick<RoomMember, 'id' | 'oderId'>,
|
||||
now = Date.now()
|
||||
): RoomMember[] {
|
||||
const nextMembers = pruneRoomMembers(members, now).map((member) => {
|
||||
const isPreviousOwner =
|
||||
member.role === 'host'
|
||||
|| (!!previousOwner?.id && member.id === previousOwner.id)
|
||||
|| (!!previousOwner?.oderId && member.oderId === previousOwner.oderId);
|
||||
|
||||
return isPreviousOwner
|
||||
? { ...member,
|
||||
role: 'member' as const }
|
||||
: member;
|
||||
});
|
||||
|
||||
if (!nextOwner || !(nextOwner.id || nextOwner.oderId))
|
||||
return pruneRoomMembers(nextMembers, now);
|
||||
|
||||
const existingNextOwner = findRoomMember(nextMembers, nextOwner.id || nextOwner.oderId);
|
||||
const nextOwnerMember: RoomMember = {
|
||||
id: existingNextOwner?.id || nextOwner.id || nextOwner.oderId || '',
|
||||
oderId: existingNextOwner?.oderId || nextOwner.oderId || undefined,
|
||||
username: existingNextOwner?.username || nextOwner.username || '',
|
||||
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
|
||||
avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined,
|
||||
role: 'host',
|
||||
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
|
||||
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now
|
||||
};
|
||||
|
||||
return upsertRoomMember(nextMembers, nextOwnerMember, now);
|
||||
}
|
||||
|
||||
/** Update a persisted member role without touching presence timestamps. */
|
||||
export function updateRoomMemberRole(
|
||||
members: RoomMember[] = [],
|
||||
identifier: string,
|
||||
role: RoomMember['role']
|
||||
): RoomMember[] {
|
||||
const nextMembers = pruneRoomMembers(members);
|
||||
const memberIndex = nextMembers.findIndex((member) => member.id === identifier || member.oderId === identifier);
|
||||
|
||||
if (memberIndex < 0)
|
||||
return nextMembers;
|
||||
|
||||
const updatedMembers = [...nextMembers];
|
||||
|
||||
updatedMembers[memberIndex] = {
|
||||
...updatedMembers[memberIndex],
|
||||
role
|
||||
};
|
||||
|
||||
return pruneRoomMembers(updatedMembers);
|
||||
}
|
||||
|
||||
/** Compare two room rosters after normalization and pruning. */
|
||||
export function areRoomMembersEqual(
|
||||
firstMembers: RoomMember[] = [],
|
||||
secondMembers: RoomMember[] = []
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
return JSON.stringify(pruneRoomMembers(firstMembers, now)) === JSON.stringify(pruneRoomMembers(secondMembers, now));
|
||||
}
|
||||
82
toju-app/src/app/store/rooms/rooms.actions.ts
Normal file
82
toju-app/src/app/store/rooms/rooms.actions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Rooms store actions using `createActionGroup`.
|
||||
*/
|
||||
import {
|
||||
createActionGroup,
|
||||
emptyProps,
|
||||
props
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
Room,
|
||||
RoomSettings,
|
||||
RoomPermissions,
|
||||
Channel
|
||||
} from '../../shared-kernel';
|
||||
import { type ServerInfo } from '../../domains/server-directory';
|
||||
|
||||
export const RoomsActions = createActionGroup({
|
||||
source: 'Rooms',
|
||||
events: {
|
||||
'Load Rooms': emptyProps(),
|
||||
'Load Rooms Success': props<{ rooms: Room[] }>(),
|
||||
'Load Rooms Failure': props<{ error: string }>(),
|
||||
|
||||
'Search Servers': props<{ query: string }>(),
|
||||
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
||||
'Search Servers Failure': props<{ error: string }>(),
|
||||
|
||||
'Create Room': props<{
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
isPrivate?: boolean;
|
||||
password?: string;
|
||||
sourceId?: string;
|
||||
sourceUrl?: string;
|
||||
}>(),
|
||||
'Create Room Success': props<{ room: Room }>(),
|
||||
'Create Room Failure': props<{ error: string }>(),
|
||||
|
||||
'Join Room': props<{ roomId: string; password?: string; serverInfo?: Partial<ServerInfo> & { name: string } }>(),
|
||||
'Join Room Success': props<{ room: Room }>(),
|
||||
'Join Room Failure': props<{ error: string }>(),
|
||||
|
||||
'Leave Room': emptyProps(),
|
||||
'Leave Room Success': emptyProps(),
|
||||
|
||||
'View Server': props<{ room: Room; skipBanCheck?: boolean }>(),
|
||||
'View Server Success': props<{ room: Room }>(),
|
||||
|
||||
'Delete Room': props<{ roomId: string }>(),
|
||||
'Delete Room Success': props<{ roomId: string }>(),
|
||||
|
||||
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
|
||||
'Forget Room Success': props<{ roomId: string }>(),
|
||||
|
||||
'Update Room Settings': props<{ roomId: string; settings: Partial<RoomSettings> }>(),
|
||||
'Update Room Settings Success': props<{ roomId: string; settings: RoomSettings }>(),
|
||||
'Update Room Settings Failure': props<{ error: string }>(),
|
||||
|
||||
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
||||
|
||||
'Update Server Icon': props<{ roomId: string; icon: string }>(),
|
||||
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||
'Update Server Icon Failure': props<{ error: string }>(),
|
||||
|
||||
'Set Current Room': props<{ room: Room }>(),
|
||||
'Clear Current Room': emptyProps(),
|
||||
|
||||
'Update Room': props<{ roomId: string; changes: Partial<Room> }>(),
|
||||
'Receive Room Update': props<{ room: Partial<Room> }>(),
|
||||
|
||||
'Select Channel': props<{ channelId: string }>(),
|
||||
'Add Channel': props<{ channel: Channel }>(),
|
||||
'Remove Channel': props<{ channelId: string }>(),
|
||||
'Rename Channel': props<{ channelId: string; name: string }>(),
|
||||
|
||||
'Clear Search Results': emptyProps(),
|
||||
'Set Connecting': props<{ isConnecting: boolean }>(),
|
||||
'Set Signal Server Reconnecting': props<{ isReconnecting: boolean }>(),
|
||||
'Set Signal Server Compatibility Error': props<{ message: string | null }>()
|
||||
}
|
||||
});
|
||||
1566
toju-app/src/app/store/rooms/rooms.effects.ts
Normal file
1566
toju-app/src/app/store/rooms/rooms.effects.ts
Normal file
File diff suppressed because it is too large
Load Diff
459
toju-app/src/app/store/rooms/rooms.reducer.ts
Normal file
459
toju-app/src/app/store/rooms/rooms.reducer.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import {
|
||||
Room,
|
||||
RoomSettings,
|
||||
Channel
|
||||
} from '../../shared-kernel';
|
||||
import { type ServerInfo } from '../../domains/server-directory';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { pruneRoomMembers } from './room-members.helpers';
|
||||
|
||||
/** Default channels for a new server */
|
||||
export function defaultChannels(): Channel[] {
|
||||
return [
|
||||
{ id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
position: 0 },
|
||||
{ id: 'random',
|
||||
name: 'random',
|
||||
type: 'text',
|
||||
position: 1 },
|
||||
{ id: 'vc-general',
|
||||
name: 'General',
|
||||
type: 'voice',
|
||||
position: 0 },
|
||||
{ id: 'vc-afk',
|
||||
name: 'AFK',
|
||||
type: 'voice',
|
||||
position: 1 }
|
||||
];
|
||||
}
|
||||
|
||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||
function deduplicateRooms(rooms: Room[]): Room[] {
|
||||
const seen = new Map<string, Room>();
|
||||
|
||||
for (const room of rooms) {
|
||||
seen.set(room.id, room);
|
||||
}
|
||||
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Normalize room defaults and prune any stale persisted member entries. */
|
||||
function enrichRoom(room: Room): Room {
|
||||
return {
|
||||
...room,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
channels: room.channels || defaultChannels(),
|
||||
members: pruneRoomMembers(room.members || [])
|
||||
};
|
||||
}
|
||||
|
||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const normalizedRoom = enrichRoom(room);
|
||||
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
|
||||
|
||||
if (idx >= 0) {
|
||||
const updated = [...savedRooms];
|
||||
|
||||
updated[idx] = normalizedRoom;
|
||||
return updated;
|
||||
}
|
||||
|
||||
return [...savedRooms, normalizedRoom];
|
||||
}
|
||||
|
||||
/** State shape for the rooms feature slice. */
|
||||
export interface RoomsState {
|
||||
/** The room the user is currently viewing. */
|
||||
currentRoom: Room | null;
|
||||
/** All rooms persisted locally (joined or created). */
|
||||
savedRooms: Room[];
|
||||
/** Editable settings for the current room. */
|
||||
roomSettings: RoomSettings | null;
|
||||
/** Results returned from the server directory search. */
|
||||
searchResults: ServerInfo[];
|
||||
/** Whether a server directory search is in progress. */
|
||||
isSearching: boolean;
|
||||
/** Whether a connection to a room is being established. */
|
||||
isConnecting: boolean;
|
||||
/** Whether the user is connected to a room. */
|
||||
isConnected: boolean;
|
||||
/** Whether the current room is using locally cached data while reconnecting. */
|
||||
isSignalServerReconnecting: boolean;
|
||||
/** Banner message when the viewed room's signaling endpoint is incompatible. */
|
||||
signalServerCompatibilityError: string | null;
|
||||
/** Whether rooms are being loaded from local storage. */
|
||||
loading: boolean;
|
||||
/** Most recent error message, if any. */
|
||||
error: string | null;
|
||||
/** ID of the currently selected text channel. */
|
||||
activeChannelId: string;
|
||||
}
|
||||
|
||||
export const initialState: RoomsState = {
|
||||
currentRoom: null,
|
||||
savedRooms: [],
|
||||
roomSettings: null,
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
|
||||
export const roomsReducer = createReducer(
|
||||
initialState,
|
||||
|
||||
// Load rooms
|
||||
on(RoomsActions.loadRooms, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
|
||||
...state,
|
||||
savedRooms: deduplicateRooms(rooms.map(enrichRoom)),
|
||||
loading: false
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error
|
||||
})),
|
||||
|
||||
// Search servers
|
||||
on(RoomsActions.searchServers, (state) => ({
|
||||
...state,
|
||||
isSearching: true,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersSuccess, (state, { servers }) => ({
|
||||
...state,
|
||||
searchResults: servers,
|
||||
isSearching: false
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isSearching: false,
|
||||
error
|
||||
})),
|
||||
|
||||
// Create room
|
||||
on(RoomsActions.createRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
||||
const enriched = enrichRoom(room);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.createRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error
|
||||
})),
|
||||
|
||||
// Join room
|
||||
on(RoomsActions.joinRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
||||
const enriched = enrichRoom(room);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error
|
||||
})),
|
||||
|
||||
// Leave room
|
||||
on(RoomsActions.leaveRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true
|
||||
})),
|
||||
|
||||
on(RoomsActions.leaveRoomSuccess, (state) => ({
|
||||
...state,
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnecting: false,
|
||||
isConnected: false
|
||||
})),
|
||||
|
||||
// View server - just switch the viewed room, stay connected
|
||||
on(RoomsActions.viewServer, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||
const enriched = enrichRoom(room);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: 'general'
|
||||
};
|
||||
}),
|
||||
|
||||
// Update room settings
|
||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||
...state,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|
||||
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom) {
|
||||
return {
|
||||
...state,
|
||||
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRoom = enrichRoom({
|
||||
...baseRoom,
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
topic: settings.topic,
|
||||
isPrivate: settings.isPrivate,
|
||||
password: settings.password === '' ? undefined : (settings.password ?? baseRoom.password),
|
||||
hasPassword:
|
||||
typeof settings.hasPassword === 'boolean'
|
||||
? settings.hasPassword
|
||||
: (typeof settings.password === 'string'
|
||||
? settings.password.trim().length > 0
|
||||
: baseRoom.hasPassword),
|
||||
maxUsers: settings.maxUsers
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings,
|
||||
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
error
|
||||
})),
|
||||
|
||||
// Delete room
|
||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
|
||||
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
|
||||
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
||||
})),
|
||||
|
||||
// Forget room (local only)
|
||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
isSignalServerReconnecting: state.currentRoom?.id === roomId ? false : state.isSignalServerReconnecting,
|
||||
signalServerCompatibilityError: state.currentRoom?.id === roomId ? null : state.signalServerCompatibilityError,
|
||||
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
||||
})),
|
||||
|
||||
// Set current room
|
||||
on(RoomsActions.setCurrentRoom, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: enrichRoom(room),
|
||||
savedRooms: upsertRoom(state.savedRooms, room),
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true
|
||||
})),
|
||||
|
||||
// Clear current room
|
||||
on(RoomsActions.clearCurrentRoom, (state) => ({
|
||||
...state,
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isSignalServerReconnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: false
|
||||
})),
|
||||
|
||||
// Update room
|
||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|
||||
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||
|
||||
if (!baseRoom)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...baseRoom,
|
||||
...changes });
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
// Update server icon success
|
||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||
if (state.currentRoom?.id !== roomId)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom,
|
||||
icon,
|
||||
iconUpdatedAt });
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const updatedRoom = enrichRoom({ ...state.currentRoom,
|
||||
...room });
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
// Clear search results
|
||||
on(RoomsActions.clearSearchResults, (state) => ({
|
||||
...state,
|
||||
searchResults: []
|
||||
})),
|
||||
|
||||
// Set connecting
|
||||
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
|
||||
...state,
|
||||
isConnecting
|
||||
})),
|
||||
|
||||
on(RoomsActions.setSignalServerReconnecting, (state, { isReconnecting }) => ({
|
||||
...state,
|
||||
isSignalServerReconnecting: isReconnecting
|
||||
})),
|
||||
|
||||
on(RoomsActions.setSignalServerCompatibilityError, (state, { message }) => ({
|
||||
...state,
|
||||
signalServerCompatibilityError: message
|
||||
})),
|
||||
|
||||
// Channel management
|
||||
on(RoomsActions.selectChannel, (state, { channelId }) => ({
|
||||
...state,
|
||||
activeChannelId: channelId
|
||||
})),
|
||||
|
||||
on(RoomsActions.addChannel, (state, { channel }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = [...existing, channel];
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
|
||||
activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||
if (!state.currentRoom)
|
||||
return state;
|
||||
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
|
||||
name } : channel);
|
||||
const updatedRoom = { ...state.currentRoom,
|
||||
channels: updatedChannels };
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: updatedRoom,
|
||||
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||
};
|
||||
})
|
||||
);
|
||||
80
toju-app/src/app/store/rooms/rooms.selectors.ts
Normal file
80
toju-app/src/app/store/rooms/rooms.selectors.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { RoomsState } from './rooms.reducer';
|
||||
|
||||
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
||||
export const selectCurrentRoom = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.currentRoom
|
||||
);
|
||||
export const selectRoomSettings = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.roomSettings
|
||||
);
|
||||
export const selectSearchResults = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.searchResults
|
||||
);
|
||||
export const selectIsSearching = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isSearching
|
||||
);
|
||||
export const selectIsConnecting = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnecting
|
||||
);
|
||||
export const selectIsConnected = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnected
|
||||
);
|
||||
export const selectIsSignalServerReconnecting = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isSignalServerReconnecting
|
||||
);
|
||||
export const selectSignalServerCompatibilityError = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.signalServerCompatibilityError
|
||||
);
|
||||
export const selectRoomsError = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.error
|
||||
);
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.id ?? null
|
||||
);
|
||||
export const selectCurrentRoomName = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.name ?? ''
|
||||
);
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.hostId
|
||||
);
|
||||
export const selectSavedRooms = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.savedRooms
|
||||
);
|
||||
export const selectRoomsLoading = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.loading
|
||||
);
|
||||
export const selectActiveChannelId = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.activeChannelId
|
||||
);
|
||||
export const selectCurrentRoomChannels = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.channels ?? []
|
||||
);
|
||||
export const selectTextChannels = createSelector(
|
||||
selectCurrentRoomChannels,
|
||||
(channels) => channels
|
||||
.filter((channel) => channel.type === 'text')
|
||||
.sort((channelA, channelB) => channelA.position - channelB.position)
|
||||
);
|
||||
export const selectVoiceChannels = createSelector(
|
||||
selectCurrentRoomChannels,
|
||||
(channels) => channels
|
||||
.filter((channel) => channel.type === 'voice')
|
||||
.sort((channelA, channelB) => channelA.position - channelB.position)
|
||||
);
|
||||
Reference in New Issue
Block a user