Fix issues with server navigation
This commit is contained in:
@@ -1,28 +1,38 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
import { lucidePlus } from '@ng-icons/lucide';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import {
|
||||||
|
EMPTY,
|
||||||
|
Subject,
|
||||||
|
catchError,
|
||||||
|
switchMap,
|
||||||
|
tap
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
import { Room, User } from '../../shared-kernel';
|
import { Room, User } from '../../shared-kernel';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
|
||||||
import { VoiceSessionFacade } from '../../domains/voice-session';
|
import { VoiceSessionFacade } from '../../domains/voice-session';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { NotificationsFacade } from '../../domains/notifications';
|
import { NotificationsFacade } from '../../domains/notifications';
|
||||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
import {
|
||||||
|
type ServerInfo,
|
||||||
|
ServerDirectoryFacade
|
||||||
|
} from '../../domains/server-directory';
|
||||||
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
import {
|
import {
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@@ -49,11 +59,12 @@ export class ServersRailComponent {
|
|||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private voiceSession = inject(VoiceSessionFacade);
|
private voiceSession = inject(VoiceSessionFacade);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
private notifications = inject(NotificationsFacade);
|
private notifications = inject(NotificationsFacade);
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
private banLookupRequestVersion = 0;
|
private banLookupRequestVersion = 0;
|
||||||
|
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
@@ -79,6 +90,13 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.savedRoomJoinRequests
|
||||||
|
.pipe(
|
||||||
|
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
@@ -102,7 +120,7 @@ export class ServersRailComponent {
|
|||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinSavedRoom(room: Room): Promise<void> {
|
joinSavedRoom(room: Room): void {
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -110,27 +128,14 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.isRoomBanned(room)) {
|
if (this.isRoomMarkedBanned(room)) {
|
||||||
this.bannedServerName.set(room.name);
|
this.bannedServerName.set(room.name);
|
||||||
this.showBannedDialog.set(true);
|
this.showBannedDialog.set(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
|
this.activateSavedRoom(room);
|
||||||
sourceId: room.sourceId,
|
this.savedRoomJoinRequests.next({ room });
|
||||||
sourceUrl: room.sourceUrl
|
|
||||||
});
|
|
||||||
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
|
|
||||||
|
|
||||||
this.prepareVoiceContext(room);
|
|
||||||
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
|
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
|
||||||
skipBanCheck: true }));
|
|
||||||
} else {
|
|
||||||
await this.attemptJoinRoom(room);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closeBannedDialog(): void {
|
closeBannedDialog(): void {
|
||||||
@@ -145,13 +150,15 @@ export class ServersRailComponent {
|
|||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmPasswordJoin(): Promise<void> {
|
confirmPasswordJoin(): void {
|
||||||
const room = this.passwordPromptRoom();
|
const room = this.passwordPromptRoom();
|
||||||
|
|
||||||
if (!room)
|
if (!room)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await this.attemptJoinRoom(room, this.joinPassword());
|
this.joinPasswordError.set(null);
|
||||||
|
this.savedRoomJoinRequests.next({ room,
|
||||||
|
password: this.joinPassword() });
|
||||||
}
|
}
|
||||||
|
|
||||||
isRoomMarkedBanned(room: Room): boolean {
|
isRoomMarkedBanned(room: Room): boolean {
|
||||||
@@ -261,19 +268,6 @@ export class ServersRailComponent {
|
|||||||
this.bannedRoomLookup.set(Object.fromEntries(entries));
|
this.bannedRoomLookup.set(Object.fromEntries(entries));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isRoomBanned(room: Room): Promise<boolean> {
|
|
||||||
const currentUser = this.currentUser();
|
|
||||||
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
|
||||||
|
|
||||||
if (!currentUser && !persistedUserId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bans = await this.db.getBansForRoom(room.id);
|
|
||||||
|
|
||||||
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareVoiceContext(room: Room): void {
|
private prepareVoiceContext(room: Room): void {
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
@@ -284,17 +278,24 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
|
private activateSavedRoom(room: Room): void {
|
||||||
|
this.prepareVoiceContext(room);
|
||||||
|
this.closePasswordDialog();
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
|
this.store.dispatch(RoomsActions.viewServer({ room,
|
||||||
|
skipBanCheck: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestJoinInBackground(room: Room, password?: string) {
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
if (!currentUserId)
|
if (!currentUserId)
|
||||||
return;
|
return EMPTY;
|
||||||
|
|
||||||
this.joinPasswordError.set(null);
|
this.joinPasswordError.set(null);
|
||||||
|
|
||||||
try {
|
return this.serverDirectory.requestJoin({
|
||||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
userPublicKey: currentUser?.oderId || currentUserId,
|
userPublicKey: currentUser?.oderId || currentUserId,
|
||||||
@@ -303,48 +304,56 @@ export class ServersRailComponent {
|
|||||||
}, {
|
}, {
|
||||||
sourceId: room.sourceId,
|
sourceId: room.sourceId,
|
||||||
sourceUrl: room.sourceUrl
|
sourceUrl: room.sourceUrl
|
||||||
}));
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this.closePasswordDialog();
|
||||||
|
this.store.dispatch(
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: this.toRoomRefreshChanges(room, response.server)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.closePasswordDialog();
|
if (this.currentRoom()?.id === room.id) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
|
||||||
RoomsActions.joinRoom({
|
|
||||||
roomId: room.id,
|
|
||||||
serverInfo: {
|
|
||||||
...this.toServerInfo(room),
|
|
||||||
...response.server,
|
|
||||||
channels:
|
|
||||||
Array.isArray(response.server.channels) && response.server.channels.length > 0
|
|
||||||
? response.server.channels
|
|
||||||
: room.channels
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
catchError((error: unknown) => {
|
||||||
|
this.handleBackgroundJoinError(room, error);
|
||||||
|
return EMPTY;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
}
|
||||||
const serverError = error as {
|
|
||||||
error?: { error?: string; errorCode?: string };
|
|
||||||
};
|
|
||||||
const errorCode = serverError?.error?.errorCode;
|
|
||||||
const message = serverError?.error?.error || 'Failed to join server';
|
|
||||||
|
|
||||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
private handleBackgroundJoinError(room: Room, error: unknown): void {
|
||||||
this.passwordPromptRoom.set(room);
|
const serverError = error as {
|
||||||
this.showPasswordDialog.set(true);
|
error?: { error?: string; errorCode?: string };
|
||||||
this.joinPasswordError.set(message);
|
status?: number;
|
||||||
return;
|
};
|
||||||
}
|
const errorCode = serverError?.error?.errorCode;
|
||||||
|
const message = serverError?.error?.error || 'Failed to join server';
|
||||||
|
|
||||||
if (errorCode === 'BANNED') {
|
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||||
this.bannedServerName.set(room.name);
|
this.passwordPromptRoom.set(room);
|
||||||
this.showBannedDialog.set(true);
|
this.showPasswordDialog.set(true);
|
||||||
return;
|
this.joinPasswordError.set(message);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.shouldFallbackToOfflineView(error)) {
|
if (errorCode === 'BANNED') {
|
||||||
this.closePasswordDialog();
|
this.closePasswordDialog();
|
||||||
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
this.bannedRoomLookup.update((lookup) => ({
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room,
|
...lookup,
|
||||||
skipBanCheck: true }));
|
[room.id]: true
|
||||||
}
|
}));
|
||||||
|
this.bannedServerName.set(room.name);
|
||||||
|
this.showBannedDialog.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldFallbackToOfflineView(error) && this.currentRoom()?.id === room.id) {
|
||||||
|
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,22 +371,27 @@ export class ServersRailComponent {
|
|||||||
|| (typeof status === 'number' && status >= 500);
|
|| (typeof status === 'number' && status >= 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toServerInfo(room: Room) {
|
private toRoomRefreshChanges(room: Room, server: ServerInfo): Partial<Room> {
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
name: server.name,
|
||||||
name: room.name,
|
description: server.description,
|
||||||
description: room.description,
|
topic: server.topic ?? room.topic,
|
||||||
hostName: room.hostId || 'Unknown',
|
hostId: server.ownerId || room.hostId,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: server.userCount,
|
||||||
maxUsers: room.maxUsers ?? 50,
|
maxUsers: server.maxUsers,
|
||||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
hasPassword:
|
||||||
isPrivate: room.isPrivate,
|
typeof server.hasPassword === 'boolean'
|
||||||
createdAt: room.createdAt,
|
? server.hasPassword
|
||||||
ownerId: room.hostId,
|
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
|
||||||
channels: room.channels,
|
isPrivate: server.isPrivate,
|
||||||
sourceId: room.sourceId,
|
createdAt: server.createdAt,
|
||||||
sourceName: room.sourceName,
|
channels:
|
||||||
sourceUrl: room.sourceUrl
|
Array.isArray(server.channels) && server.channels.length > 0
|
||||||
|
? server.channels
|
||||||
|
: room.channels,
|
||||||
|
sourceId: server.sourceId ?? room.sourceId,
|
||||||
|
sourceName: server.sourceName ?? room.sourceName,
|
||||||
|
sourceUrl: server.sourceUrl ?? room.sourceUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export class RoomsEffects {
|
|||||||
* and prevents false join/leave sounds during state re-syncs.
|
* and prevents false join/leave sounds during state re-syncs.
|
||||||
*/
|
*/
|
||||||
private knownVoiceUsers = new Set<string>();
|
private knownVoiceUsers = new Set<string>();
|
||||||
|
private roomNavigationRequestVersion = 0;
|
||||||
|
private latestNavigatedRoomId: string | null = null;
|
||||||
|
|
||||||
/** Loads all saved rooms from the local database. */
|
/** Loads all saved rooms from the local database. */
|
||||||
loadRooms$ = createEffect(() =>
|
loadRooms$ = createEffect(() =>
|
||||||
@@ -416,8 +418,11 @@ export class RoomsEffects {
|
|||||||
user,
|
user,
|
||||||
savedRooms
|
savedRooms
|
||||||
]) => {
|
]) => {
|
||||||
|
const navigationRequestVersion = this.beginRoomNavigation(room.id);
|
||||||
|
|
||||||
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
void this.connectToRoomSignaling(room, user ?? null, undefined, savedRooms, {
|
||||||
showCompatibilityError: true
|
showCompatibilityError: true,
|
||||||
|
navigationRequestVersion
|
||||||
});
|
});
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
@@ -478,9 +483,11 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
const activateViewedRoom = () => {
|
const activateViewedRoom = () => {
|
||||||
const oderId = user.oderId || this.webrtc.peerId();
|
const oderId = user.oderId || this.webrtc.peerId();
|
||||||
|
const navigationRequestVersion = this.beginRoomNavigation(room.id);
|
||||||
|
|
||||||
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
void this.connectToRoomSignaling(room, user, oderId, savedRooms, {
|
||||||
showCompatibilityError: true
|
showCompatibilityError: true,
|
||||||
|
navigationRequestVersion
|
||||||
});
|
});
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
@@ -1621,14 +1628,19 @@ export class RoomsEffects {
|
|||||||
user: User | null,
|
user: User | null,
|
||||||
resolvedOderId?: string,
|
resolvedOderId?: string,
|
||||||
savedRooms: Room[] = [],
|
savedRooms: Room[] = [],
|
||||||
options: { showCompatibilityError?: boolean } = {}
|
options: { showCompatibilityError?: boolean; navigationRequestVersion?: number } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
const shouldShowCompatibilityError = options.showCompatibilityError ?? false;
|
||||||
|
const navigationRequestVersion = options.navigationRequestVersion;
|
||||||
const compatibilitySelector = this.resolveCompatibilitySelector(room);
|
const compatibilitySelector = this.resolveCompatibilitySelector(room);
|
||||||
const isCompatible = compatibilitySelector === null
|
const isCompatible = compatibilitySelector === null
|
||||||
? true
|
? true
|
||||||
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
|
: await this.serverDirectory.ensureEndpointVersionCompatibility(compatibilitySelector);
|
||||||
|
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isCompatible) {
|
if (!isCompatible) {
|
||||||
if (shouldShowCompatibilityError) {
|
if (shouldShowCompatibilityError) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
@@ -1653,6 +1665,10 @@ export class RoomsEffects {
|
|||||||
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
const sameSignalRooms = this.getRoomsForSignalingUrl(this.includeRoom(savedRooms, room), wsUrl);
|
||||||
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
const backgroundRooms = sameSignalRooms.filter((candidate) => candidate.id !== room.id);
|
||||||
const joinCurrentEndpointRooms = () => {
|
const joinCurrentEndpointRooms = () => {
|
||||||
|
if (!this.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
this.webrtc.setCurrentServer(room.id);
|
||||||
this.webrtc.identify(oderId, displayName, wsUrl);
|
this.webrtc.identify(oderId, displayName, wsUrl);
|
||||||
|
|
||||||
@@ -1676,7 +1692,7 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||||
next: (connected) => {
|
next: (connected) => {
|
||||||
if (!connected)
|
if (!connected || !this.isCurrentRoomNavigation(room.id, navigationRequestVersion))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
joinCurrentEndpointRooms();
|
joinCurrentEndpointRooms();
|
||||||
@@ -1788,6 +1804,22 @@ export class RoomsEffects {
|
|||||||
return roomMatch ? roomMatch[1] : null;
|
return roomMatch ? roomMatch[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private beginRoomNavigation(roomId: string): number {
|
||||||
|
this.roomNavigationRequestVersion += 1;
|
||||||
|
this.latestNavigatedRoomId = roomId;
|
||||||
|
|
||||||
|
return this.roomNavigationRequestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentRoomNavigation(roomId: string, navigationRequestVersion?: number): boolean {
|
||||||
|
if (typeof navigationRequestVersion !== 'number') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigationRequestVersion === this.roomNavigationRequestVersion
|
||||||
|
&& roomId === this.latestNavigatedRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||||
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
|
||||||
return 'host';
|
return 'host';
|
||||||
|
|||||||
Reference in New Issue
Block a user