Лента уведомлений: колокольчик, экран и честная отметка «прочитано»
Пуш живёт до первого смахивания, сообщение в боте тонет в переписке — и разговор «мне ничего не приходило» упирался в слово против слова. Теперь у водителя есть место, куда посмотреть, а у парка — отметка о прочтении, которая ставится ровно тогда, когда человек открыл ленту или нажал на уведомление. Отмечаем ПОСЛЕ показа списка, а не при загрузке: иначе счётчик гаснет у того, кто мазнул по экрану и ушёл. Время первого прочтения бэкенд не переписывает. Нажатие по пушу: воркер сам отметить не может (токен лежит в хранилище страницы, ему недоступном), поэтому номер уведомления приезжает в пуше, воркер добавляет его к адресу параметром, а страница отмечает и параметр убирает. Колокольчик по умолчанию выключен в общей шапке и включён только на главной: шапка стоит на каждом экране, и незачем ходить за счётчиком с оплаты. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { useEffect } from "react";
|
||||||
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { useStaff } from "@/store/staff";
|
import { useStaff } from "@/store/staff";
|
||||||
import { ImpersonationBar } from "@/components/ImpersonationBar";
|
import { ImpersonationBar } from "@/components/ImpersonationBar";
|
||||||
@@ -13,6 +14,8 @@ import { DevLoginPage } from "@/pages/DevLoginPage";
|
|||||||
import { DebugPage } from "@/pages/DebugPage";
|
import { DebugPage } from "@/pages/DebugPage";
|
||||||
import { MockPayPage } from "@/pages/MockPayPage";
|
import { MockPayPage } from "@/pages/MockPayPage";
|
||||||
import { FineDetailPage } from "@/pages/FineDetailPage";
|
import { FineDetailPage } from "@/pages/FineDetailPage";
|
||||||
|
import { NotificationsPage } from "@/pages/NotificationsPage";
|
||||||
|
import { markNotificationsRead } from "@/api/driver";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const token = useAuth((s) => s.token);
|
const token = useAuth((s) => s.token);
|
||||||
@@ -28,12 +31,37 @@ function RequireStaff({ children }: { children: ReactNode }) {
|
|||||||
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
|
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нажатие по пушу — единственный честный признак, что уведомление увидели.
|
||||||
|
* Сам воркер отметить его не может: токен водителя лежит в хранилище страницы,
|
||||||
|
* ему недоступном. Поэтому воркер добавляет к адресу `?n=<номер>`, а отмечает
|
||||||
|
* страница — и тут же убирает параметр, чтобы он не остался в истории.
|
||||||
|
*/
|
||||||
|
function MarkPushRead() {
|
||||||
|
const loc = useLocation();
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(loc.search);
|
||||||
|
const raw = params.get("n");
|
||||||
|
if (!raw) return;
|
||||||
|
const id = Number(raw);
|
||||||
|
if (Number.isFinite(id) && id > 0) void markNotificationsRead([id], "push");
|
||||||
|
params.delete("n");
|
||||||
|
const rest = params.toString();
|
||||||
|
nav(loc.pathname + (rest ? `?${rest}` : ""), { replace: true });
|
||||||
|
}, [loc.search, loc.pathname, nav]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
||||||
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
|
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
|
||||||
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
|
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
|
||||||
<ImpersonationBar />
|
<ImpersonationBar />
|
||||||
|
<MarkPushRead />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/dev" element={<DevLoginPage />} />
|
<Route path="/dev" element={<DevLoginPage />} />
|
||||||
@@ -44,6 +72,7 @@ export default function App() {
|
|||||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||||||
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
|
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
|
||||||
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
|
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
|
||||||
|
<Route path="/notifications" element={<RequireAuth><NotificationsPage /></RequireAuth>} />
|
||||||
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -187,6 +187,29 @@ export const getFines = async (status: "all" | "open") =>
|
|||||||
export const getFine = async (id: number) =>
|
export const getFine = async (id: number) =>
|
||||||
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
|
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
|
||||||
|
|
||||||
|
export const NotificationSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
kind: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
body: z.string(),
|
||||||
|
url: z.string().nullable().default(null),
|
||||||
|
created_at: z.string().nullable().default(null),
|
||||||
|
read_at: z.string().nullable().default(null),
|
||||||
|
});
|
||||||
|
export type DriverNotification = z.infer<typeof NotificationSchema>;
|
||||||
|
|
||||||
|
export const NotificationsSchema = z.object({
|
||||||
|
items: z.array(NotificationSchema),
|
||||||
|
unread: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getNotifications = async () =>
|
||||||
|
NotificationsSchema.parse(await api.get("driver/notifications").json());
|
||||||
|
|
||||||
|
/** Отметить прочитанным. Без списка — «всё»: так отмечает открытие ленты. */
|
||||||
|
export const markNotificationsRead = async (ids?: number[], source: "app" | "push" = "app") =>
|
||||||
|
api.post("driver/notifications/read", { json: { ids: ids ?? null, source } }).json();
|
||||||
|
|
||||||
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
|
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
|
||||||
|
|
||||||
export const getPushKey = async () =>
|
export const getPushKey = async () =>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { NotificationsBell } from "@/pages/NotificationsPage";
|
||||||
|
|
||||||
/** Долгое нажатие, после которого открывается вход для сотрудника. */
|
/** Долгое нажатие, после которого открывается вход для сотрудника. */
|
||||||
const LONG_PRESS_MS = 1500;
|
const LONG_PRESS_MS = 1500;
|
||||||
|
|
||||||
export function AppHeader() {
|
/** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка
|
||||||
|
* стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа.
|
||||||
|
* Включается там, откуда водитель и правда пойдёт читать: на главной. */
|
||||||
|
export function AppHeader({ bell = false }: { bell?: boolean } = {}) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const timer = useRef<number | null>(null);
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -20,7 +25,7 @@ export function AppHeader() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="mb-4">
|
<header className="mb-4 flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className="text-sm font-bold select-none"
|
className="text-sm font-bold select-none"
|
||||||
onPointerDown={start}
|
onPointerDown={start}
|
||||||
@@ -30,6 +35,7 @@ export function AppHeader() {
|
|||||||
>
|
>
|
||||||
Премиум Водитель
|
Премиум Водитель
|
||||||
</span>
|
</span>
|
||||||
|
{bell && <NotificationsBell />}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
|
|
||||||
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
||||||
vi.mock("@/api/driver", () => ({
|
vi.mock("@/api/driver", () => ({
|
||||||
|
// Колокольчик в шапке главного экрана спрашивает счётчик непрочитанного.
|
||||||
|
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
|
||||||
|
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
|
||||||
getBalance: vi.fn(async () => ({
|
getBalance: vi.fn(async () => ({
|
||||||
accounts: [
|
accounts: [
|
||||||
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function BalancePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AppHeader />
|
<AppHeader bell />
|
||||||
|
|
||||||
<NotificationsPrompt />
|
<NotificationsPrompt />
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ vi.mock("@/api/staff", () => ({
|
|||||||
vi.mock("@/api/driver", () => ({
|
vi.mock("@/api/driver", () => ({
|
||||||
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
|
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
|
||||||
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
|
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
|
||||||
|
// Тесты доходят до главного экрана, а у него в шапке колокольчик.
|
||||||
|
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
|
||||||
|
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("@/api/driver", () => ({
|
||||||
|
getNotifications: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{ id: 2, kind: "fine", title: "Новый штраф", body: "Постановление от 18.08.2026.",
|
||||||
|
url: "/fines/12", created_at: "2026-08-18T10:15:00+10:00", read_at: null },
|
||||||
|
{ id: 1, kind: "topup", title: "Платёж получен", body: "Пополнение принято.",
|
||||||
|
url: "/", created_at: "2026-08-17T09:00:00+10:00", read_at: "2026-08-17T09:05:00+10:00" },
|
||||||
|
],
|
||||||
|
unread: 1,
|
||||||
|
})),
|
||||||
|
markNotificationsRead: vi.fn(async () => ({ marked: 1 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { NotificationsPage } from "./NotificationsPage";
|
||||||
|
import * as api from "@/api/driver";
|
||||||
|
|
||||||
|
const show = () => render(<MemoryRouter><NotificationsPage /></MemoryRouter>);
|
||||||
|
|
||||||
|
describe("лента уведомлений", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("показывает, что мы писали водителю", async () => {
|
||||||
|
show();
|
||||||
|
expect(await screen.findByText("Новый штраф")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Платёж получен")).toBeInTheDocument();
|
||||||
|
// Дата человеку — ДД.ММ.ГГГГ, не ISO (текст постановления содержит её же).
|
||||||
|
expect(screen.getByText("18.08.2026 10:15")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("открытие ленты отмечает непрочитанное", async () => {
|
||||||
|
show();
|
||||||
|
await screen.findByText("Новый штраф");
|
||||||
|
// Это единственный честный источник отметки: пуш живёт до смахивания, а
|
||||||
|
// сообщение в боте тонет в переписке — там прочтения не видно вовсе.
|
||||||
|
expect(api.markNotificationsRead).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без непрочитанного лишний запрос не делаем", async () => {
|
||||||
|
vi.mocked(api.getNotifications).mockResolvedValueOnce({ items: [], unread: 0 });
|
||||||
|
show();
|
||||||
|
expect(await screen.findByText(/Пока ничего не приходило/)).toBeInTheDocument();
|
||||||
|
expect(api.markNotificationsRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
|
||||||
|
import { getNotifications, markNotificationsRead, type DriverNotification } from "@/api/driver";
|
||||||
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
import { OperationIcon } from "@/components/operationIcon";
|
||||||
|
|
||||||
|
// Значок берём тем же подбором, что и в выписке: событие приложения переводим
|
||||||
|
// в тег 1С, чтобы штраф в ленте выглядел так же, как штраф в списке операций.
|
||||||
|
const ICON_BY_KIND: Record<string, string> = {
|
||||||
|
fine: "Штраф ГИБДД",
|
||||||
|
topup: "Пополнение баланса",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лента уведомлений водителя.
|
||||||
|
*
|
||||||
|
* Нужна не только водителю. Пуш живёт до первого смахивания, сообщение в боте
|
||||||
|
* тонет в переписке — и разговор «мне ничего не приходило» упирался в слово
|
||||||
|
* против слова. Здесь у водителя есть место, куда посмотреть, а у парка —
|
||||||
|
* честная отметка «прочитано»: она ставится ровно тогда, когда человек открыл
|
||||||
|
* этот экран или нажал на само уведомление, и ниоткуда больше.
|
||||||
|
*
|
||||||
|
* Отмечаем прочитанным ПОСЛЕ показа, а не при загрузке: иначе счётчик гаснет у
|
||||||
|
* того, кто просто мазнул по экрану и ушёл.
|
||||||
|
*/
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [items, setItems] = useState<DriverNotification[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getNotifications()
|
||||||
|
.then((r) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setItems(r.items);
|
||||||
|
if (r.unread > 0) void markNotificationsRead();
|
||||||
|
})
|
||||||
|
.catch(() => alive && setError("Не удалось загрузить уведомления"));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<AppHeader />
|
||||||
|
<h1 className="text-lg font-bold mb-3">Уведомления</h1>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-muted">{error}</div>}
|
||||||
|
{!items && !error && <Spinner />}
|
||||||
|
|
||||||
|
{items && items.length === 0 && (
|
||||||
|
<div className="text-sm text-muted">
|
||||||
|
Пока ничего не приходило. Здесь будут появляться сообщения о штрафах и платежах.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items?.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
className="flex gap-3 items-start text-left bg-surface border border-line rounded-xl p-3"
|
||||||
|
onClick={() => n.url && nav(n.url)}
|
||||||
|
>
|
||||||
|
<OperationIcon title={ICON_BY_KIND[n.kind] ?? ""} />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold text-sm">{n.title}</span>
|
||||||
|
{!n.read_at && <span className="w-2 h-2 rounded-full bg-accent shrink-0" />}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-muted mt-0.5">{n.body}</span>
|
||||||
|
<span className="block text-xs text-muted mt-1">{when(n.created_at)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дата человеку — ДД.ММ.ГГГГ и время, без ISO. */
|
||||||
|
function when(ts: string | null): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const d = new Date(ts);
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колокольчик со счётчиком непрочитанного — для шапки главного экрана. */
|
||||||
|
export function NotificationsBell() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getNotifications()
|
||||||
|
.then((r) => alive && setUnread(r.unread))
|
||||||
|
// Молча: колокольчик — не тот элемент, ради которого стоит показывать ошибку.
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="relative p-1 -m-1" aria-label="Уведомления"
|
||||||
|
onClick={() => nav("/notifications")}>
|
||||||
|
<Bell size={22} strokeWidth={1.5} />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent
|
||||||
|
text-white text-[10px] leading-4 text-center">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ interface PushPayload {
|
|||||||
body?: string;
|
body?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
/** Номер уведомления в журнале — по нему отмечается прочтение. */
|
||||||
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener("push", (event: PushEvent) => {
|
self.addEventListener("push", (event: PushEvent) => {
|
||||||
@@ -43,14 +45,19 @@ self.addEventListener("push", (event: PushEvent) => {
|
|||||||
badge: "/icons/icon-192.png",
|
badge: "/icons/icon-192.png",
|
||||||
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
|
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
|
||||||
tag: data.tag,
|
tag: data.tag,
|
||||||
data: { url: data.url || "/" },
|
data: { url: data.url || "/", id: data.id },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const target = (event.notification.data?.url as string) || "/";
|
const url = (event.notification.data?.url as string) || "/";
|
||||||
|
const id = event.notification.data?.id as number | undefined;
|
||||||
|
// Нажатие — единственный честный признак, что уведомление увидели. Отметить
|
||||||
|
// его прямо отсюда нельзя: токен водителя лежит в хранилище страницы, а
|
||||||
|
// воркеру оно недоступно. Поэтому номер едет параметром, а отмечает страница.
|
||||||
|
const target = id ? `${url}${url.includes("?") ? "&" : "?"}n=${id}` : url;
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user