Files
Toju/toju-app/src/app/store/users/users.reducer.ts
Myx bc2fa7de22 fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
2026-04-26 22:54:13 +02:00

623 lines
18 KiB
TypeScript

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