feat(invites): страница приглашения со splash и переопределением
- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
присоединение, недействительная ссылка)
This commit is contained in:
@@ -12,6 +12,13 @@ export interface InviteGuildPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
/** Кто позвал: имя видно на странице приглашения (AGENT.md 7.9). */
|
||||
inviter?: {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
};
|
||||
/** Оформление сервера для страницы приглашения (AGENT.md 7.4). */
|
||||
banner_file_id?: string;
|
||||
splash_file_id?: string;
|
||||
@@ -25,6 +32,8 @@ export interface InviteGuildPreview {
|
||||
export interface Invite {
|
||||
code: string;
|
||||
guild_id: string;
|
||||
/** Переопределение splash для этого приглашения (AGENT.md 7.9). */
|
||||
background_file_id?: string;
|
||||
channel_id?: string;
|
||||
creator_id?: string;
|
||||
max_uses: number;
|
||||
@@ -40,6 +49,8 @@ export interface InvitePreview {
|
||||
code: string;
|
||||
guild_id: string;
|
||||
guild: InviteGuildPreview;
|
||||
/** Переопределение splash самого приглашения (приоритетнее splash сервера). */
|
||||
background_file_id?: string;
|
||||
}
|
||||
|
||||
export const inviteQueryKey = (code: string) => ['invites', code] as const;
|
||||
@@ -113,6 +124,7 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
|
||||
if (guild === null || guildId === undefined) {
|
||||
throw new Error('malformed invite payload');
|
||||
}
|
||||
const background = asString(invite?.['background_file_id']);
|
||||
const preview: InvitePreview = {
|
||||
code: asString(invite?.['code']) ?? code,
|
||||
guild_id: guildId,
|
||||
@@ -140,9 +152,26 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
|
||||
if (icon !== undefined) {
|
||||
preview.guild.icon_file_id = icon;
|
||||
}
|
||||
const inviter = asRecord(guild['inviter']);
|
||||
const inviterId = asString(inviter?.['id']);
|
||||
if (inviter !== null && inviterId !== undefined) {
|
||||
const inviterUser: NonNullable<InviteGuildPreview['inviter']> = {
|
||||
id: inviterId,
|
||||
username: asString(inviter['username']) ?? '',
|
||||
display_name: asString(inviter['display_name']) ?? '',
|
||||
};
|
||||
const inviterAvatar = asString(inviter['avatar_file_id']);
|
||||
if (inviterAvatar !== undefined) {
|
||||
inviterUser.avatar_file_id = inviterAvatar;
|
||||
}
|
||||
preview.guild.inviter = inviterUser;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
preview.guild.description = description;
|
||||
}
|
||||
if (background !== undefined) {
|
||||
preview.background_file_id = background;
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
|
||||
@@ -434,7 +434,17 @@
|
||||
"join": "Join",
|
||||
"joining": "Joining…",
|
||||
"members": "Members: {{count}}",
|
||||
"alreadyMember": "You are already on this server"
|
||||
"alreadyMember": "You are already on this server",
|
||||
"invitedTo": "You are invited to a server",
|
||||
"inviter": "Invited by {{name}}",
|
||||
"accept": "Join server",
|
||||
"open": "Open server",
|
||||
"loginHint": "Log in to join — we will bring you back to this page.",
|
||||
"login": "Log in",
|
||||
"register": "Register",
|
||||
"toLogin": "Log in",
|
||||
"notFoundTitle": "This invite does not work",
|
||||
"notFound": "The link is invalid, expired or revoked. Ask for a new one."
|
||||
},
|
||||
"chat": {
|
||||
"log": "Message log of {{name}}",
|
||||
|
||||
@@ -434,7 +434,17 @@
|
||||
"join": "Присоединиться",
|
||||
"joining": "Присоединяемся…",
|
||||
"members": "Участников: {{count}}",
|
||||
"alreadyMember": "Вы уже на этом сервере"
|
||||
"alreadyMember": "Вы уже на этом сервере",
|
||||
"invitedTo": "Вас приглашают на сервер",
|
||||
"inviter": "Приглашает {{name}}",
|
||||
"accept": "Присоединиться",
|
||||
"open": "Открыть сервер",
|
||||
"loginHint": "Войдите, чтобы присоединиться — вернём вас на эту страницу.",
|
||||
"login": "Войти",
|
||||
"register": "Регистрация",
|
||||
"toLogin": "Войти",
|
||||
"notFoundTitle": "Приглашение не работает",
|
||||
"notFound": "Ссылка недействительна, истекла или отозвана. Попросите новую."
|
||||
},
|
||||
"chat": {
|
||||
"log": "Лента сообщений комнаты {{name}}",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { acceptInvite, fetchInvite, inviteQueryKey } from '@/api/invites';
|
||||
import { instanceQueryKey } from '@/api/instance';
|
||||
import { myGuildsQueryKey } from '@/api/users';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { roleColorHex } from '@/lib/format';
|
||||
import { isUnauthorized } from '@/lib/http';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* Страница приглашения `/invite/{code}` (AGENT.md 7.9, критерий Фазы 4):
|
||||
* показывает splash сервера или переопределение приглашения, карточку сервера,
|
||||
* кто позвал, и присоединяет одним нажатием. Доступна без входа — гость видит
|
||||
* предпросмотр, а кнопка ведёт на вход с возвратом на эту же страницу.
|
||||
*/
|
||||
export default function InvitePage() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ code?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const describeError = useApiErrorMessage();
|
||||
const currentUser = useCurrentUser();
|
||||
const guilds = useSessionStore((state) => state.guilds);
|
||||
const [joinError, setJoinError] = useState<unknown>(null);
|
||||
const code = params.code ?? '';
|
||||
|
||||
// Гость (401) видит карточку приглашения: сервер отдаёт её без сессии.
|
||||
const guest = currentUser.isError && isUnauthorized(currentUser.error);
|
||||
const preview = useQuery({
|
||||
queryKey: inviteQueryKey(code),
|
||||
queryFn: ({ signal }) => fetchInvite(code, signal),
|
||||
enabled: code !== '' && (currentUser.isSuccess || guest),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const join = useMutation({
|
||||
mutationFn: () => acceptInvite(code),
|
||||
onSuccess: (guildId) => {
|
||||
setJoinError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||
void navigate(`/app/${guildId}`);
|
||||
},
|
||||
onError: (cause: unknown) => setJoinError(cause),
|
||||
});
|
||||
|
||||
const guild = preview.data?.guild;
|
||||
const memberGuild = useMemo(
|
||||
() => (guild === undefined ? undefined : guilds.find((item) => item.id === guild.id)),
|
||||
[guild, guilds],
|
||||
);
|
||||
|
||||
// Фон страницы: переопределение приглашения → splash → баннер сервера.
|
||||
const backgroundFileId =
|
||||
preview.data?.background_file_id ?? guild?.splash_file_id ?? guild?.banner_file_id;
|
||||
const accent = guild?.accent_color;
|
||||
const accentHex = accent === undefined || accent === 0 ? null : roleColorHex(accent);
|
||||
|
||||
const inviterName =
|
||||
guild?.inviter === undefined
|
||||
? ''
|
||||
: guild.inviter.display_name === ''
|
||||
? guild.inviter.username
|
||||
: guild.inviter.display_name;
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg px-4 py-10">
|
||||
{backgroundFileId === undefined ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0"
|
||||
style={
|
||||
accentHex === null
|
||||
? undefined
|
||||
: { background: `radial-gradient(circle at 50% 0%, ${accentHex}55, transparent 60%)` }
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div aria-hidden="true" className="absolute inset-0">
|
||||
<AnimatedImage
|
||||
src={fileContentUrl(backgroundFileId)}
|
||||
className="h-full w-full object-cover opacity-50"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-bg/70" data-testid="invite-background" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section
|
||||
className="relative w-full max-w-lg rounded-[var(--radius-lg)] border border-border/60 bg-surface-1/95 p-6 shadow-[var(--shadow-3)] gl-pop-in"
|
||||
data-testid="invite-page"
|
||||
>
|
||||
{code === '' ? (
|
||||
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||
) : preview.isError || (currentUser.isError && !guest) ? (
|
||||
<>
|
||||
<h1 className="text-lg font-semibold">{t('invites.notFoundTitle')}</h1>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||
<p className="mt-3 text-sm text-danger">{describeError(preview.error)}</p>
|
||||
<Button className="mt-4" onClick={() => void navigate('/login')}>
|
||||
{t('invites.toLogin')}
|
||||
</Button>
|
||||
</>
|
||||
) : preview.isPending || currentUser.isPending ? (
|
||||
<SkeletonLines rows={4} />
|
||||
) : guild === undefined ? (
|
||||
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-fg-muted">
|
||||
{t('invites.invitedTo')}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Avatar
|
||||
name={guild.name}
|
||||
seed={guild.id}
|
||||
fileId={guild.icon_file_id}
|
||||
size="lg"
|
||||
shape="square"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold" data-testid="invite-guild-name">
|
||||
{guild.name}
|
||||
</h1>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('invites.members', { count: guild.member_count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guild.description === undefined || guild.description === '' ? null : (
|
||||
<p className="mt-4 text-sm text-fg-muted">{guild.description}</p>
|
||||
)}
|
||||
|
||||
{guild.inviter === undefined ? null : (
|
||||
<div className="mt-4 flex items-center gap-2" data-testid="invite-inviter">
|
||||
<Avatar
|
||||
name={inviterName}
|
||||
seed={guild.inviter.id}
|
||||
fileId={guild.inviter.avatar_file_id}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-fg-muted">
|
||||
{t('invites.inviter', { name: inviterName })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentUser.data === undefined || guest ? (
|
||||
// Гость видит предпросмотр: вход вернёт его на эту же страницу.
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<p className="text-sm text-fg-muted">{t('invites.loginHint')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
data-testid="invite-login"
|
||||
onClick={() => void navigate(`/login?next=/invite/${encodeURIComponent(code)}`)}
|
||||
>
|
||||
{t('invites.login')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
void navigate(`/register?next=/invite/${encodeURIComponent(code)}`)
|
||||
}
|
||||
>
|
||||
{t('invites.register')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : memberGuild !== undefined || guild.is_member ? (
|
||||
<Button
|
||||
className="mt-6"
|
||||
data-testid="invite-open"
|
||||
onClick={() => void navigate(`/app/${guild.id}`)}
|
||||
>
|
||||
{t('invites.open')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="mt-6"
|
||||
data-testid="invite-accept"
|
||||
disabled={join.isPending}
|
||||
onClick={() => join.mutate()}
|
||||
>
|
||||
{t(join.isPending ? 'invites.joining' : 'invites.accept')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{joinError === null ? null : <ErrorNotice className="mt-3" error={joinError} />}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { login } from '@/api/auth';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
@@ -30,8 +30,13 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [needsTotp, setNeedsTotp] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const from = (location.state as LocationState | null)?.from ?? '/app';
|
||||
// Возврат после входа: `?next=` (страница приглашения) важнее состояния
|
||||
// перехода из AuthGuard, но только для внутренних путей.
|
||||
const next = searchParams.get('next');
|
||||
const stateFrom = (location.state as LocationState | null)?.from ?? null;
|
||||
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
export const InvitePage = lazy(() => import('@/pages/InvitePage'));
|
||||
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AppLayout,
|
||||
IndexRedirect,
|
||||
FriendsPage,
|
||||
InvitePage,
|
||||
GuildView,
|
||||
InstanceSettingsPage,
|
||||
LoginPage,
|
||||
@@ -29,6 +30,8 @@ export const routes = [
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'status', element: <StatusPage /> },
|
||||
// Страница приглашения: доступна без входа, гость видит предпросмотр.
|
||||
{ path: 'invite/:code', element: <InvitePage /> },
|
||||
{
|
||||
element: <AuthGuard allowIncompleteOnboarding />,
|
||||
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
findRequest,
|
||||
installFetch,
|
||||
installFailingFetch,
|
||||
json,
|
||||
makeUser,
|
||||
renderApp,
|
||||
resetStores,
|
||||
type FetchRoute,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Страница приглашения `/invite/{code}` (AGENT.md 7.9): splash сервера или
|
||||
* переопределение приглашения, карточка сервера, пригласивший и присоединение.
|
||||
*/
|
||||
|
||||
const user = makeUser();
|
||||
|
||||
function guildPreview(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'g-9',
|
||||
name: 'Сообщество',
|
||||
icon_file_id: 'icon-9',
|
||||
splash_file_id: 'splash-9',
|
||||
description: 'Обсуждаем всё подряд',
|
||||
member_count: 12,
|
||||
is_member: false,
|
||||
inviter: { id: 'user-2', username: 'bob', display_name: 'Боб', avatar_file_id: 'av-2' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function routes(
|
||||
options: { guild?: Record<string, unknown>; background?: string } = {},
|
||||
): FetchRoute[] {
|
||||
return [
|
||||
{
|
||||
match: '/api/v1/invites/abc123',
|
||||
method: 'POST',
|
||||
response: () => json({ guild_id: 'g-9' }),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/invites/abc123',
|
||||
response: () =>
|
||||
json({
|
||||
invite: {
|
||||
code: 'abc123',
|
||||
guild_id: 'g-9',
|
||||
...(options.background === undefined ? {} : { background_file_id: options.background }),
|
||||
},
|
||||
guild: options.guild ?? guildPreview(),
|
||||
}),
|
||||
},
|
||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||
{ match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) },
|
||||
{
|
||||
match: '/api/v1/guilds/g-9',
|
||||
response: () => json({ guild: { id: 'g-9', name: 'Сообщество' } }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
describe('страница приглашения', () => {
|
||||
it('показывает splash сервера, карточку и пригласившего', async () => {
|
||||
installFetch(routes());
|
||||
renderApp('/invite/abc123');
|
||||
|
||||
const page = await screen.findByTestId('invite-page');
|
||||
expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество');
|
||||
expect(screen.getByText('Обсуждаем всё подряд')).toBeVisible();
|
||||
expect(screen.getByText('Участников: 12')).toBeVisible();
|
||||
expect(screen.getByTestId('invite-inviter')).toHaveTextContent('Боб');
|
||||
// Splash сервера становится фоном страницы.
|
||||
const background = screen.getByTestId('invite-background').parentElement;
|
||||
expect(background?.querySelector('img')?.getAttribute('src')).toBe('/files/splash-9');
|
||||
expect(page).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('переопределение приглашения важнее splash сервера', async () => {
|
||||
installFetch(routes({ background: 'invite-bg' }));
|
||||
renderApp('/invite/abc123');
|
||||
|
||||
await screen.findByTestId('invite-page');
|
||||
const background = await screen.findByTestId('invite-background');
|
||||
expect(background.parentElement?.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'/files/invite-bg',
|
||||
);
|
||||
});
|
||||
|
||||
it('присоединяет по кнопке и открывает сервер', async () => {
|
||||
const fetchMock = installFetch(routes());
|
||||
const { router } = renderApp('/invite/abc123');
|
||||
|
||||
await userEvent.click(await screen.findByTestId('invite-accept'));
|
||||
await waitFor(() => {
|
||||
expect(findRequest(fetchMock, { url: '/invites/abc123', method: 'POST' })).toBeDefined();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/app/g-9');
|
||||
});
|
||||
});
|
||||
|
||||
it('для участника показывает «Открыть сервер» вместо присоединения', async () => {
|
||||
installFetch(routes({ guild: guildPreview({ is_member: true }) }));
|
||||
renderApp('/invite/abc123');
|
||||
|
||||
expect(await screen.findByTestId('invite-open')).toBeVisible();
|
||||
expect(screen.queryByTestId('invite-accept')).toBeNull();
|
||||
});
|
||||
|
||||
it('недействительное приглашение объясняет ошибку', async () => {
|
||||
installFailingFetch();
|
||||
renderApp('/invite/broken');
|
||||
|
||||
expect(await screen.findByTestId('invite-page')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Приглашение не работает')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('страница приглашения для гостя', () => {
|
||||
it('показывает карточку и ведёт на вход с возвратом', async () => {
|
||||
installFetch([
|
||||
{
|
||||
match: '/api/v1/invites/abc123',
|
||||
response: () =>
|
||||
json({
|
||||
invite: { code: 'abc123', guild_id: 'g-9' },
|
||||
guild: guildPreview({ is_member: false }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: '/api/v1/users/@me',
|
||||
response: () => json({ error: { code: 'auth.session_expired', message: 'no' } }, 401),
|
||||
},
|
||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||
]);
|
||||
renderApp('/invite/abc123');
|
||||
|
||||
expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество');
|
||||
await userEvent.click(screen.getByTestId('invite-login'));
|
||||
expect(await screen.findByLabelText('Почта', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user