663 lines
20 KiB
TypeScript
663 lines
20 KiB
TypeScript
/**
|
|
* Users store effects (load, kick, ban, host election, profile persistence).
|
|
*/
|
|
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import { Injectable, inject } from '@angular/core';
|
|
import {
|
|
Actions,
|
|
createEffect,
|
|
ofType
|
|
} from '@ngrx/effects';
|
|
import { Store } from '@ngrx/store';
|
|
import {
|
|
of,
|
|
from,
|
|
EMPTY
|
|
} from 'rxjs';
|
|
import {
|
|
map,
|
|
mergeMap,
|
|
catchError,
|
|
withLatestFrom,
|
|
tap,
|
|
switchMap
|
|
} from 'rxjs/operators';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { UsersActions } from './users.actions';
|
|
import { RoomsActions } from '../rooms/rooms.actions';
|
|
import {
|
|
selectAllUsers,
|
|
selectCurrentUser,
|
|
selectCurrentUserId,
|
|
selectHostId
|
|
} from './users.selectors';
|
|
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
|
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
|
import { DatabaseService } from '../../infrastructure/persistence';
|
|
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
|
import {
|
|
canManageMember,
|
|
resolveLegacyRole,
|
|
resolveRoomPermission
|
|
} from '../../domains/access-control';
|
|
import {
|
|
BanEntry,
|
|
ChatEvent,
|
|
Room,
|
|
User
|
|
} from '../../shared-kernel';
|
|
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
|
|
|
type IncomingModerationExtraAction =
|
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
|
| ReturnType<typeof UsersActions.kickUserSuccess>
|
|
| ReturnType<typeof UsersActions.banUserSuccess>;
|
|
|
|
type IncomingModerationAction =
|
|
| ReturnType<typeof RoomsActions.updateRoom>
|
|
| IncomingModerationExtraAction;
|
|
|
|
@Injectable()
|
|
export class UsersEffects {
|
|
private actions$ = inject(Actions);
|
|
private store = inject(Store);
|
|
private db = inject(DatabaseService);
|
|
private serverDirectory = inject(ServerDirectoryFacade);
|
|
private webrtc = inject(RealtimeSessionFacade);
|
|
|
|
// Load current user from storage
|
|
/** Loads the persisted current user from the local database on startup. */
|
|
loadCurrentUser$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.loadCurrentUser),
|
|
switchMap(() =>
|
|
from(this.db.getCurrentUser()).pipe(
|
|
switchMap((user) => {
|
|
if (!user) {
|
|
return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' }));
|
|
}
|
|
|
|
const sanitizedUser = this.clearStartupVoiceConnection(user);
|
|
|
|
if (sanitizedUser === user) {
|
|
return of(UsersActions.loadCurrentUserSuccess({ user }));
|
|
}
|
|
|
|
return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe(
|
|
map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })),
|
|
// If persistence fails, still load a sanitized in-memory user to keep UI correct.
|
|
catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })))
|
|
);
|
|
}),
|
|
catchError((error) =>
|
|
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
private clearStartupVoiceConnection(user: User): User {
|
|
const voiceState = user.voiceState;
|
|
|
|
if (!voiceState)
|
|
return user;
|
|
|
|
const hasStaleConnectionState =
|
|
voiceState.isConnected ||
|
|
voiceState.isSpeaking ||
|
|
voiceState.roomId !== undefined ||
|
|
voiceState.serverId !== undefined;
|
|
|
|
if (!hasStaleConnectionState)
|
|
return user;
|
|
|
|
return {
|
|
...user,
|
|
voiceState: {
|
|
...voiceState,
|
|
isConnected: false,
|
|
isSpeaking: false,
|
|
roomId: undefined,
|
|
serverId: undefined
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Loads all users associated with a specific room from the local database. */
|
|
loadRoomUsers$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.loadRoomUsers),
|
|
switchMap(({ roomId }) =>
|
|
from(this.db.getUsersByRoom(roomId)).pipe(
|
|
map((users) => UsersActions.loadRoomUsersSuccess({ users })),
|
|
catchError((error) =>
|
|
of(UsersActions.loadRoomUsersFailure({ error: error.message }))
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Kicks a user from the room (requires moderator+ role). Broadcasts a kick signal. */
|
|
kickUser$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.kickUser),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
{ userId, roomId },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
if (!currentUser)
|
|
return EMPTY;
|
|
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return EMPTY;
|
|
|
|
const canKick = this.canKickInRoom(room, currentUser, currentRoom, userId);
|
|
|
|
if (!canKick)
|
|
return EMPTY;
|
|
|
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
|
|
|
return this.serverDirectory.kickServerMember(
|
|
room.id,
|
|
{
|
|
actorUserId: currentUser.id,
|
|
actorRole: currentUser.role,
|
|
targetUserId: userId
|
|
},
|
|
this.toSourceSelector(room)
|
|
).pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to revoke server membership on kick:', error);
|
|
return of(void 0);
|
|
}),
|
|
mergeMap(() => {
|
|
this.webrtc.broadcastMessage({
|
|
type: 'kick',
|
|
targetUserId: userId,
|
|
roomId: room.id,
|
|
kickedBy: currentUser.id
|
|
});
|
|
|
|
return currentRoom?.id === room.id
|
|
? [
|
|
RoomsActions.updateRoom({ roomId: room.id,
|
|
changes: { members: nextMembers } }),
|
|
UsersActions.kickUserSuccess({ userId,
|
|
roomId: room.id })
|
|
]
|
|
: of(
|
|
RoomsActions.updateRoom({ roomId: room.id,
|
|
changes: { members: nextMembers } })
|
|
);
|
|
})
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
|
|
banUser$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.banUser),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms),
|
|
this.store.select(selectAllUsers)
|
|
),
|
|
mergeMap(([
|
|
{ userId, roomId, displayName, reason, expiresAt },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms,
|
|
allUsers
|
|
]) => {
|
|
if (!currentUser)
|
|
return EMPTY;
|
|
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!room)
|
|
return EMPTY;
|
|
|
|
const canBan = this.canBanInRoom(room, currentUser, currentRoom, userId);
|
|
|
|
if (!canBan)
|
|
return EMPTY;
|
|
|
|
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
|
const targetMember = findRoomMember(room.members ?? [], userId);
|
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
|
const ban: BanEntry = {
|
|
oderId: uuidv4(),
|
|
userId,
|
|
roomId: room.id,
|
|
bannedBy: currentUser.id,
|
|
displayName: displayName || targetUser?.displayName || targetMember?.displayName,
|
|
reason,
|
|
expiresAt,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
return this.serverDirectory.banServerMember(
|
|
room.id,
|
|
{
|
|
actorUserId: currentUser.id,
|
|
actorRole: currentUser.role,
|
|
targetUserId: userId,
|
|
banId: ban.oderId,
|
|
displayName: ban.displayName,
|
|
reason,
|
|
expiresAt
|
|
},
|
|
this.toSourceSelector(room)
|
|
).pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to persist server ban:', error);
|
|
return of(void 0);
|
|
}),
|
|
switchMap(() =>
|
|
from(this.db.saveBan(ban)).pipe(
|
|
tap(() => {
|
|
this.webrtc.broadcastMessage({
|
|
type: 'ban',
|
|
targetUserId: userId,
|
|
roomId: room.id,
|
|
bannedBy: currentUser.id,
|
|
ban
|
|
});
|
|
}),
|
|
mergeMap(() => {
|
|
const actions: (ReturnType<typeof RoomsActions.updateRoom>
|
|
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
|
|
RoomsActions.updateRoom({ roomId: room.id,
|
|
changes: { members: nextMembers } })
|
|
];
|
|
|
|
if (currentRoom?.id === room.id) {
|
|
actions.push(UsersActions.banUserSuccess({ userId,
|
|
roomId: room.id,
|
|
ban }));
|
|
}
|
|
|
|
return actions;
|
|
}),
|
|
catchError(() => EMPTY)
|
|
)
|
|
)
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Removes a ban entry locally and broadcasts the change to peers in the same room. */
|
|
unbanUser$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.unbanUser),
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
switchMap(([
|
|
{ roomId, oderId },
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
|
|
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
|
|
return EMPTY;
|
|
|
|
return this.serverDirectory.unbanServerMember(
|
|
room.id,
|
|
{
|
|
actorUserId: currentUser.id,
|
|
actorRole: currentUser.role,
|
|
banId: oderId
|
|
},
|
|
this.toSourceSelector(room)
|
|
).pipe(
|
|
catchError((error) => {
|
|
console.error('Failed to remove server ban:', error);
|
|
return of(void 0);
|
|
}),
|
|
switchMap(() =>
|
|
from(this.db.removeBan(oderId)).pipe(
|
|
tap(() => {
|
|
this.webrtc.broadcastMessage({
|
|
type: 'unban',
|
|
roomId: room.id,
|
|
banOderId: oderId
|
|
});
|
|
}),
|
|
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
|
catchError(() => EMPTY)
|
|
)
|
|
)
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Loads all active bans for the current room from the local database. */
|
|
loadBans$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.loadBans),
|
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
|
switchMap(([, currentRoom]) => {
|
|
if (!currentRoom) {
|
|
return of(UsersActions.loadBansSuccess({ bans: [] }));
|
|
}
|
|
|
|
return from(this.db.getBansForRoom(currentRoom.id)).pipe(
|
|
map((bans) => UsersActions.loadBansSuccess({ bans })),
|
|
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
|
|
);
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Applies incoming moderation events from peers to local persistence and UI state. */
|
|
incomingModerationEvents$ = createEffect(() =>
|
|
this.webrtc.onMessageReceived.pipe(
|
|
withLatestFrom(
|
|
this.store.select(selectCurrentUser),
|
|
this.store.select(selectCurrentRoom),
|
|
this.store.select(selectSavedRooms)
|
|
),
|
|
mergeMap(([
|
|
event,
|
|
currentUser,
|
|
currentRoom,
|
|
savedRooms
|
|
]) => {
|
|
switch (event.type) {
|
|
case 'kick':
|
|
return this.handleIncomingKick(event, currentUser ?? null, currentRoom, savedRooms);
|
|
|
|
case 'ban':
|
|
return this.handleIncomingBan(event, currentUser ?? null, currentRoom, savedRooms);
|
|
|
|
case 'unban':
|
|
return this.handleIncomingUnban(event, currentRoom, savedRooms);
|
|
|
|
default:
|
|
return EMPTY;
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
/** Elects the current user as host if the previous host leaves. */
|
|
handleHostLeave$ = createEffect(() =>
|
|
this.actions$.pipe(
|
|
ofType(UsersActions.userLeft),
|
|
withLatestFrom(
|
|
this.store.select(selectHostId),
|
|
this.store.select(selectCurrentUserId)
|
|
),
|
|
mergeMap(([
|
|
{ userId },
|
|
hostId,
|
|
currentUserId
|
|
]) =>
|
|
userId === hostId && currentUserId
|
|
? of(UsersActions.updateHost({ userId: currentUserId }))
|
|
: EMPTY
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Persists user profile changes to the local database whenever the current user is updated. */
|
|
persistUser$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(
|
|
UsersActions.setCurrentUser,
|
|
UsersActions.loadCurrentUserSuccess,
|
|
UsersActions.updateCurrentUser
|
|
),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
tap(([, user]) => {
|
|
if (user) {
|
|
this.db.saveUser(user);
|
|
// Ensure current user ID is persisted when explicitly set
|
|
this.db.setCurrentUserId(user.id);
|
|
}
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
/** Keep signaling identity aligned with the current profile to avoid stale fallback names. */
|
|
syncSignalingIdentity$ = createEffect(
|
|
() =>
|
|
this.actions$.pipe(
|
|
ofType(
|
|
UsersActions.setCurrentUser,
|
|
UsersActions.loadCurrentUserSuccess
|
|
),
|
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
|
tap(([, user]) => {
|
|
if (!user)
|
|
return;
|
|
|
|
this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user));
|
|
})
|
|
),
|
|
{ dispatch: false }
|
|
);
|
|
|
|
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
|
if (!roomId)
|
|
return currentRoom;
|
|
|
|
if (currentRoom?.id === roomId)
|
|
return currentRoom;
|
|
|
|
return savedRooms.find((room) => room.id === roomId) ?? null;
|
|
}
|
|
|
|
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
|
|
const displayName = user.displayName?.trim();
|
|
|
|
if (displayName) {
|
|
return displayName;
|
|
}
|
|
|
|
return user.username?.trim() || 'User';
|
|
}
|
|
|
|
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
|
return {
|
|
sourceId: room.sourceId,
|
|
sourceUrl: room.sourceUrl
|
|
};
|
|
}
|
|
|
|
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
|
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
|
|
|
return role === 'host' || resolveRoomPermission(room, currentUser, 'manageBans');
|
|
}
|
|
|
|
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
|
|
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'kickMembers');
|
|
}
|
|
|
|
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null, targetUserId: string): boolean {
|
|
return canManageMember(room, currentUser, findRoomMember(room.members ?? [], targetUserId) ?? { id: targetUserId }, 'banMembers');
|
|
}
|
|
|
|
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
|
return resolveLegacyRole(currentRoom?.id === room.id ? currentRoom : room, currentUser);
|
|
}
|
|
|
|
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {
|
|
return {
|
|
members: removeRoomMember(room.members ?? [], targetUserId, targetUserId)
|
|
};
|
|
}
|
|
|
|
private resolveIncomingModerationActions(
|
|
room: Room,
|
|
targetUserId: string,
|
|
currentRoom: Room | null,
|
|
extra: IncomingModerationExtraAction[] = []
|
|
) {
|
|
const actions: IncomingModerationAction[] = [
|
|
RoomsActions.updateRoom({
|
|
roomId: room.id,
|
|
changes: this.removeMemberFromRoom(room, targetUserId)
|
|
})
|
|
];
|
|
|
|
if (currentRoom?.id === room.id) {
|
|
actions.push(...extra);
|
|
} else {
|
|
actions.push(...extra.filter((action) => action.type === RoomsActions.forgetRoom.type));
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
private shouldAffectVisibleUsers(room: Room, currentRoom: Room | null): boolean {
|
|
return currentRoom?.id === room.id;
|
|
}
|
|
|
|
private canForgetForTarget(
|
|
targetUserId: string,
|
|
currentUser: User | null
|
|
): ReturnType<typeof RoomsActions.forgetRoom> | null {
|
|
return this.isCurrentUserTarget(targetUserId, currentUser)
|
|
? RoomsActions.forgetRoom({ roomId: '' })
|
|
: null;
|
|
}
|
|
|
|
private isCurrentUserTarget(targetUserId: string, currentUser: User | null): boolean {
|
|
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
|
|
}
|
|
|
|
private buildIncomingBan(event: ChatEvent, targetUserId: string, roomId: string): BanEntry {
|
|
const payloadBan = event.ban && typeof event.ban === 'object'
|
|
? event.ban as Partial<BanEntry>
|
|
: null;
|
|
|
|
return {
|
|
oderId: typeof payloadBan?.oderId === 'string' ? payloadBan.oderId : uuidv4(),
|
|
userId: typeof payloadBan?.userId === 'string' ? payloadBan.userId : targetUserId,
|
|
roomId,
|
|
bannedBy:
|
|
typeof payloadBan?.bannedBy === 'string'
|
|
? payloadBan.bannedBy
|
|
: (typeof event.bannedBy === 'string' ? event.bannedBy : 'unknown'),
|
|
displayName:
|
|
typeof payloadBan?.displayName === 'string'
|
|
? payloadBan.displayName
|
|
: (typeof event.displayName === 'string' ? event.displayName : undefined),
|
|
reason:
|
|
typeof payloadBan?.reason === 'string'
|
|
? payloadBan.reason
|
|
: (typeof event.reason === 'string' ? event.reason : undefined),
|
|
expiresAt:
|
|
typeof payloadBan?.expiresAt === 'number'
|
|
? payloadBan.expiresAt
|
|
: (typeof event.expiresAt === 'number' ? event.expiresAt : undefined),
|
|
timestamp: typeof payloadBan?.timestamp === 'number' ? payloadBan.timestamp : Date.now()
|
|
};
|
|
}
|
|
|
|
private handleIncomingKick(
|
|
event: ChatEvent,
|
|
currentUser: User | null,
|
|
currentRoom: Room | null,
|
|
savedRooms: Room[]
|
|
) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
|
|
|
|
if (!room || !targetUserId)
|
|
return EMPTY;
|
|
|
|
const actions = this.resolveIncomingModerationActions(
|
|
room,
|
|
targetUserId,
|
|
currentRoom,
|
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
|
: [
|
|
UsersActions.kickUserSuccess({ userId: targetUserId,
|
|
roomId: room.id })
|
|
]
|
|
);
|
|
|
|
return actions;
|
|
}
|
|
|
|
private handleIncomingBan(
|
|
event: ChatEvent,
|
|
currentUser: User | null,
|
|
currentRoom: Room | null,
|
|
savedRooms: Room[]
|
|
) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
|
|
|
|
if (!room || !targetUserId)
|
|
return EMPTY;
|
|
|
|
const ban = this.buildIncomingBan(event, targetUserId, room.id);
|
|
const actions = this.resolveIncomingModerationActions(
|
|
room,
|
|
targetUserId,
|
|
currentRoom,
|
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
|
: [
|
|
UsersActions.banUserSuccess({ userId: targetUserId,
|
|
roomId: room.id,
|
|
ban })
|
|
]
|
|
);
|
|
|
|
return from(this.db.saveBan(ban)).pipe(
|
|
mergeMap(() => (actions.length > 0 ? actions : EMPTY)),
|
|
catchError(() => EMPTY)
|
|
);
|
|
}
|
|
|
|
private handleIncomingUnban(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[]) {
|
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
|
const banOderId = typeof event.banOderId === 'string'
|
|
? event.banOderId
|
|
: (typeof event.oderId === 'string' ? event.oderId : '');
|
|
|
|
if (!room || !banOderId)
|
|
return EMPTY;
|
|
|
|
return from(this.db.removeBan(banOderId)).pipe(
|
|
mergeMap(() => (currentRoom?.id === room.id
|
|
? of(UsersActions.unbanUserSuccess({ oderId: banOderId }))
|
|
: EMPTY)),
|
|
catchError(() => EMPTY)
|
|
);
|
|
}
|
|
}
|