Move toju-app into own its folder

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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