mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
feat(navigator): wired-tools-style hook split (Store + 3 filters)
Splits the 492-line useNavigator god-hook into a useBetween-backed useNavigatorStore closure plus three flat-shape filters (useNavigatorData, useNavigatorUiState, useNavigatorActions), mirroring the wired-tools layout. sendSearch + reloadCurrentSearch are extracted as named actions out of NavigatorView locals. Door-mode handling is removed from this store and lives in useDoorState (committed previously) - see GetGuestRoomResultEvent and GenericErrorEvent dual-subscription with mutually exclusive filters. The simpleAlert dependency is lifted out of the useBetween scope via a module-level _simpleAlert ref + _injectSimpleAlert() to avoid nested useBetween calls that corrupt use-between's module-level dispatcher state. The ref is null in tests (no events fire during smoke tests) and is populated in production by the navigator consumer before any alert is needed. The barrel index.ts no longer re-exports useNavigator. The 13 consumers will fail typecheck until the next commit migrates them; the hook files themselves are clean. Smoke test covers filter shapes. INTENTIONAL INTERMEDIATE-BROKEN COMMIT: yarn typecheck is RED at this SHA on the 13 consumer files. The next commit (consumer migration sweep) brings it back to green.
This commit is contained in:
@@ -1 +1,7 @@
|
||||
export * from './useNavigator';
|
||||
export { useNavigatorActions } from './useNavigatorActions';
|
||||
export { useNavigatorData } from './useNavigatorData';
|
||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
||||
export { useDoorState } from '../rooms/widgets/useDoorState';
|
||||
export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState';
|
||||
export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorActions = () =>
|
||||
{
|
||||
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
|
||||
return { sendSearch, reloadCurrentSearch };
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorData = () =>
|
||||
{
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData
|
||||
} = useBetween(useNavigatorStore);
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index';
|
||||
|
||||
describe('navigator filter shapes (smoke)', () =>
|
||||
{
|
||||
it('useNavigatorData returns the documented keys', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorData());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
||||
'navigatorData', 'navigatorSearches',
|
||||
'searchResult', 'topLevelContext', 'topLevelContexts'
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('useNavigatorUiState returns the 9 documented flags', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorUiState());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'isCreatorOpen', 'isLoading', 'isOpenSavesSearches',
|
||||
'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible',
|
||||
'needsInit', 'needsSearch'
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorActions());
|
||||
expect(typeof result.current.sendSearch).toBe('function');
|
||||
expect(typeof result.current.reloadCurrentSearch).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent,
|
||||
FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent,
|
||||
FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer,
|
||||
GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager,
|
||||
GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer,
|
||||
HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser,
|
||||
NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent,
|
||||
NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch,
|
||||
NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType,
|
||||
RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent,
|
||||
RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent,
|
||||
SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent,
|
||||
UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
||||
LocalizeText, NotificationAlertType, SendMessageComposer,
|
||||
TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
// Module-level reference to simpleAlert, injected by useNavigatorActions
|
||||
// (which runs in a real React dispatcher context, outside useBetween).
|
||||
// Avoids nested useBetween calls that corrupt use-between's module-level state.
|
||||
type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void;
|
||||
let _simpleAlert: SimpleAlertFn | null = null;
|
||||
export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; };
|
||||
|
||||
export const useNavigatorStore = () =>
|
||||
{
|
||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
|
||||
settingsReceived: false,
|
||||
homeRoomId: 0,
|
||||
enteredGuestRoom: null,
|
||||
currentRoomOwner: false,
|
||||
currentRoomId: 0,
|
||||
currentRoomIsStaffPick: false,
|
||||
createdFlatId: 0,
|
||||
avatarId: 0,
|
||||
roomPicker: false,
|
||||
eventMod: false,
|
||||
currentRoomRating: 0,
|
||||
canRate: true
|
||||
});
|
||||
|
||||
// Refs let handlers stay [] deps without losing access to fresh state.
|
||||
const topLevelContextsRef = useRef(topLevelContexts);
|
||||
topLevelContextsRef.current = topLevelContexts;
|
||||
const topLevelContextRef = useRef(topLevelContext);
|
||||
topLevelContextRef.current = topLevelContext;
|
||||
const searchResultRef = useRef(searchResult);
|
||||
searchResultRef.current = searchResult;
|
||||
|
||||
const simpleAlert = _simpleAlert;
|
||||
|
||||
const sendSearch = useCallback((searchValue: string, contextCode: string) =>
|
||||
{
|
||||
useNavigatorUiStore.getState().closeCreator();
|
||||
SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue));
|
||||
useNavigatorUiStore.getState().setLoading(true);
|
||||
}, []);
|
||||
|
||||
const reloadCurrentSearch = useCallback(() =>
|
||||
{
|
||||
if(!useNavigatorUiStore.getState().isReady)
|
||||
{
|
||||
useNavigatorUiStore.getState().requestSearch();
|
||||
return;
|
||||
}
|
||||
const sr = searchResultRef.current;
|
||||
if(sr)
|
||||
{
|
||||
sendSearch(sr.data, sr.code);
|
||||
return;
|
||||
}
|
||||
const ctx = topLevelContextRef.current;
|
||||
if(!ctx) return;
|
||||
sendSearch('', ctx.code);
|
||||
}, [ sendSearch ]);
|
||||
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
||||
setFavouriteRoomIds(favoriteIds);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const roomId = Number(parser.flatId);
|
||||
const added = !!parser.added;
|
||||
setFavouriteRoomIds(prev =>
|
||||
{
|
||||
const ids = (prev || []).map((x: any) => Number(x));
|
||||
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
|
||||
return ids.filter(id => id !== roomId);
|
||||
});
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomSettingsUpdatedEvent>(RoomSettingsUpdatedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.canCreate) return;
|
||||
simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title'));
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<UserInfoEvent>(UserInfoEvent, useCallback(event =>
|
||||
{
|
||||
SendMessageComposer(new GetUserFlatCatsMessageComposer());
|
||||
SendMessageComposer(new GetUserEventCatsMessageComposer());
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserPermissionsEvent>(UserPermissionsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
eventMod: parser.securityLevel >= SecurityLevel.MODERATOR,
|
||||
roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomForwardEvent>(RoomForwardEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
TryVisitRoom(parser.roomId);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomEntryInfoMessageEvent>(RoomEntryInfoMessageEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
enteredGuestRoom: null,
|
||||
currentRoomOwner: parser.isOwner,
|
||||
currentRoomId: parser.roomId
|
||||
}));
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false));
|
||||
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.roomEnter)
|
||||
{
|
||||
setNavigatorData(prev =>
|
||||
{
|
||||
const next = { ...prev };
|
||||
next.enteredGuestRoom = parser.data;
|
||||
next.currentRoomIsStaffPick = parser.staffPick;
|
||||
const isCreated = next.createdFlatId === parser.data.roomId;
|
||||
if(!isCreated && parser.data.displayRoomEntryAd)
|
||||
{
|
||||
if(GetConfigurationValue<boolean>('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd();
|
||||
}
|
||||
next.createdFlatId = 0;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if(parser.roomForward)
|
||||
{
|
||||
// Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here.
|
||||
const isOwner = parser.data.ownerName === GetSessionDataManager().userName;
|
||||
if(!isOwner && !parser.isGroupMember)
|
||||
{
|
||||
if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return;
|
||||
if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return;
|
||||
}
|
||||
if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return;
|
||||
CreateRoomSession(parser.data.roomId);
|
||||
return;
|
||||
}
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
enteredGuestRoom: parser.data,
|
||||
currentRoomIsStaffPick: parser.staffPick
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomScoreEvent>(RoomScoreEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
currentRoomRating: parser.totalLikes,
|
||||
canRate: parser.canLike
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
// -100002 (wrong password) is handled by useDoorState — skip it here.
|
||||
switch(parser.errorCode)
|
||||
{
|
||||
case 4009:
|
||||
simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4010:
|
||||
simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4011:
|
||||
simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4013:
|
||||
simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
}
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<NavigatorMetadataEvent>(NavigatorMetadataEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setTopLevelContexts(parser.topLevelContexts);
|
||||
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const contexts = topLevelContextsRef.current;
|
||||
setTopLevelContext(prev =>
|
||||
{
|
||||
let next = prev;
|
||||
if(!next) next = (contexts && contexts.length && contexts[0]) || null;
|
||||
if(!next) return null;
|
||||
if(contexts && contexts.length)
|
||||
{
|
||||
for(const ctx of contexts)
|
||||
{
|
||||
if(ctx.code === parser.result.code) next = ctx;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSearchResult(parser.result);
|
||||
useNavigatorUiStore.getState().setLoading(false);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserFlatCatsEvent>(UserFlatCatsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setCategories(parser.categories);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserEventCatsEvent>(UserEventCatsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setEventCategories(parser.categories);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
CreateRoomSession(parser.roomId);
|
||||
}, []));
|
||||
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() =>
|
||||
{
|
||||
setNavigatorData(prev => ({ ...prev, settingsReceived: false }));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
let prevSettingsReceived = false;
|
||||
setNavigatorData(prev =>
|
||||
{
|
||||
prevSettingsReceived = prev.settingsReceived;
|
||||
return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true };
|
||||
});
|
||||
if(prevSettingsReceived) return;
|
||||
if(GetRoomSessionManager().viewerSession) return;
|
||||
|
||||
let forwardType = -1;
|
||||
let forwardId = -1;
|
||||
if((GetConfigurationValue<string>('friend.id') !== undefined) && (parseInt(GetConfigurationValue<string>('friend.id')) > 0))
|
||||
{
|
||||
forwardType = 0;
|
||||
SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue<string>('friend.id'))));
|
||||
}
|
||||
if((GetConfigurationValue<number>('forward.type') !== undefined) && (GetConfigurationValue<number>('forward.id') !== undefined))
|
||||
{
|
||||
forwardType = parseInt(GetConfigurationValue<string>('forward.type'));
|
||||
forwardId = parseInt(GetConfigurationValue<string>('forward.id'));
|
||||
}
|
||||
if(forwardType === 2)
|
||||
{
|
||||
TryVisitRoom(forwardId);
|
||||
}
|
||||
else if((forwardType === -1) && (parser.roomIdToEnter > 0))
|
||||
{
|
||||
CreateLinkEvent('navigator/close');
|
||||
CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId);
|
||||
}
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomEnterErrorEvent>(RoomEnterErrorEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
switch(parser.reason)
|
||||
{
|
||||
case CantConnectMessageParser.REASON_FULL:
|
||||
simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title'));
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_QUEUE_ERROR:
|
||||
simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_BANNED:
|
||||
simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title'));
|
||||
break;
|
||||
default:
|
||||
simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
break;
|
||||
}
|
||||
if(GetRoomSessionManager().isReconnecting) return;
|
||||
VisitDesktop();
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<NavigatorOpenRoomCreatorEvent>(NavigatorOpenRoomCreatorEvent, useCallback(_event =>
|
||||
{
|
||||
CreateLinkEvent('navigator/show');
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorSearchesEvent>(NavigatorSearchesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(!parser) return;
|
||||
setNavigatorSearches(parser.searches);
|
||||
}, []));
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData,
|
||||
sendSearch, reloadCurrentSearch
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorUiState = () =>
|
||||
{
|
||||
const isVisible = useNavigatorUiStore(s => s.isVisible);
|
||||
const isReady = useNavigatorUiStore(s => s.isReady);
|
||||
const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen);
|
||||
const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen);
|
||||
const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen);
|
||||
const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches);
|
||||
const isLoading = useNavigatorUiStore(s => s.isLoading);
|
||||
const needsInit = useNavigatorUiStore(s => s.needsInit);
|
||||
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
|
||||
return {
|
||||
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
|
||||
isOpenSavesSearches, isLoading, needsInit, needsSearch
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user