623 lines
18 KiB
TypeScript
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
|
|
);
|
|
})
|
|
);
|