feat: replace notifications with import status in profile dropdown
Remove the notification system entirely and show import progress directly in the user dropdown menu. Block new imports while one is already running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
assets/bootstrap.js
vendored
4
assets/bootstrap.js
vendored
@@ -1,12 +1,12 @@
|
||||
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
|
||||
import DropdownController from './controllers/dropdown_controller.js';
|
||||
import NotificationsController from './controllers/notifications_controller.js';
|
||||
import ImportModalController from './controllers/import_modal_controller.js';
|
||||
import ImportStatusController from './controllers/import_status_controller.js';
|
||||
|
||||
const app = startStimulusApp();
|
||||
app.register('dropdown', DropdownController);
|
||||
app.register('notifications', NotificationsController);
|
||||
app.register('import-modal', ImportModalController);
|
||||
app.register('import-status', ImportStatusController);
|
||||
|
||||
// Register React components for {{ react_component() }} Twig function.
|
||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
||||
|
||||
@@ -43,6 +43,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
this._showFeedback('Import lancé !', false);
|
||||
document.dispatchEvent(new CustomEvent('import:started'));
|
||||
setTimeout(() => this.close(), 1500);
|
||||
} catch (e) {
|
||||
this._showFeedback('Une erreur est survenue.', true);
|
||||
|
||||
99
assets/controllers/import_status_controller.js
Normal file
99
assets/controllers/import_status_controller.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['item', 'importBtn', 'badge'];
|
||||
|
||||
connect() {
|
||||
this._poll();
|
||||
this._interval = setInterval(() => this._poll(), 5000);
|
||||
this._onImportStarted = () => this._poll();
|
||||
document.addEventListener('import:started', this._onImportStarted);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearInterval(this._interval);
|
||||
document.removeEventListener('import:started', this._onImportStarted);
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
try {
|
||||
const response = await fetch('/api/imports/latest');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
this._update(data);
|
||||
} catch (e) {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
_update(data) {
|
||||
if (!data) {
|
||||
this._showDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = data.status === 'pending' || data.status === 'processing';
|
||||
|
||||
if (isActive) {
|
||||
this._showActive(data);
|
||||
} else if (data.status === 'completed') {
|
||||
this._showCompleted(data);
|
||||
} else if (data.status === 'failed') {
|
||||
this._showFailed();
|
||||
} else {
|
||||
this._showDefault();
|
||||
}
|
||||
}
|
||||
|
||||
_showDefault() {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
this._removeStatus();
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showActive(data) {
|
||||
this.importBtnTarget.disabled = true;
|
||||
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
||||
|
||||
const progress = data.totalBatches > 0
|
||||
? Math.round((data.processedBatches / data.totalBatches) * 100)
|
||||
: 0;
|
||||
|
||||
this._setStatus(`${progress}% — ${data.totalFilms} films`, 'active');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showCompleted(data) {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
|
||||
const imported = data.totalFilms - data.failedFilms;
|
||||
this._setStatus(`Dernier import : ${imported}/${data.totalFilms} films`, 'completed');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showFailed() {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
this._setStatus('Dernier import : échoué', 'failed');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_setStatus(text, type) {
|
||||
let statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||
if (!statusEl) {
|
||||
statusEl = document.createElement('span');
|
||||
statusEl.className = 'import-status-text';
|
||||
this.itemTarget.appendChild(statusEl);
|
||||
}
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = `import-status-text import-status-${type}`;
|
||||
}
|
||||
|
||||
_removeStatus() {
|
||||
const statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||
if (statusEl) statusEl.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['badge', 'list'];
|
||||
|
||||
connect() {
|
||||
this._poll();
|
||||
this._interval = setInterval(() => this._poll(), 30000);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearInterval(this._interval);
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
try {
|
||||
const response = await fetch('/api/notifications');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
this._updateBadge(data.unreadCount);
|
||||
this._updateTitle(data.unreadCount);
|
||||
this._updateList(data.notifications);
|
||||
} catch (e) {
|
||||
// silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
_updateBadge(count) {
|
||||
if (count > 0) {
|
||||
this.badgeTarget.textContent = count;
|
||||
this.badgeTarget.hidden = false;
|
||||
} else {
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTitle(count) {
|
||||
const base = 'Actorle';
|
||||
document.title = count > 0 ? `(${count}) ${base}` : base;
|
||||
}
|
||||
|
||||
_updateList(notifications) {
|
||||
if (notifications.length === 0) {
|
||||
this.listTarget.innerHTML = '<p class="dropdown-empty">Aucune notification</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.listTarget.innerHTML = notifications.map(n => `
|
||||
<div class="notification-item ${n.read ? '' : 'notification-unread'}">
|
||||
<p>${this._escapeHtml(n.message)}</p>
|
||||
<time>${this._formatDate(n.createdAt)}</time>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async markRead() {
|
||||
await fetch('/api/notifications/read', { method: 'POST' });
|
||||
this._poll();
|
||||
}
|
||||
|
||||
_escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
_formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -363,35 +363,31 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Notifications ── */
|
||||
/* ── Import status ── */
|
||||
|
||||
.notifications-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
.import-status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--surface-warm);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item p {
|
||||
margin: 0 0 3px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notification-item time {
|
||||
.import-status-text {
|
||||
display: block;
|
||||
padding: 0 12px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: var(--surface-warm);
|
||||
.import-status-active {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.import-status-completed {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.import-status-failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
@@ -595,6 +591,20 @@ body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.start-loader {
|
||||
display: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: #ff6b81;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Game footer ── */
|
||||
|
||||
.game-footer {
|
||||
|
||||
35
migrations/Version20260331000001.php
Normal file
35
migrations/Version20260331000001.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260331000001 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop notification table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS notification');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE notification (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INT NOT NULL REFERENCES "user"(id),
|
||||
message VARCHAR(255) NOT NULL,
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller;
|
||||
use App\Entity\Import;
|
||||
use App\Entity\User;
|
||||
use App\Message\ProcessImportMessage;
|
||||
use App\Repository\ImportRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -19,14 +20,45 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ImportController extends AbstractController
|
||||
{
|
||||
#[Route('/api/imports/latest', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function latest(ImportRepository $importRepository): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$import = $importRepository->findLatestForUser($user);
|
||||
|
||||
if (!$import) {
|
||||
return $this->json(null);
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'id' => $import->getId(),
|
||||
'status' => $import->getStatus(),
|
||||
'totalFilms' => $import->getTotalFilms(),
|
||||
'failedFilms' => $import->getFailedFilms(),
|
||||
'processedBatches' => $import->getProcessedBatches(),
|
||||
'totalBatches' => $import->getTotalBatches(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/imports', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function create(
|
||||
Request $request,
|
||||
FilesystemOperator $defaultStorage,
|
||||
EntityManagerInterface $em,
|
||||
ImportRepository $importRepository,
|
||||
MessageBusInterface $bus,
|
||||
): JsonResponse {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
if ($importRepository->hasActiveImport($user)) {
|
||||
return $this->json(['error' => 'Un import est déjà en cours.'], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
$file = $request->files->get('file');
|
||||
|
||||
if (!$file) {
|
||||
@@ -41,9 +73,6 @@ class ImportController extends AbstractController
|
||||
return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$import = new Import();
|
||||
$import->setUser($user);
|
||||
$import->setFilePath('pending');
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class NotificationController extends AbstractController
|
||||
{
|
||||
#[Route('/api/notifications', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function index(NotificationRepository $notificationRepository): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$notifications = $notificationRepository->findRecentForUser($user);
|
||||
$unreadCount = $notificationRepository->countUnreadForUser($user);
|
||||
|
||||
return $this->json([
|
||||
'unreadCount' => $unreadCount,
|
||||
'notifications' => array_map(fn ($n) => [
|
||||
'id' => $n->getId(),
|
||||
'message' => $n->getMessage(),
|
||||
'read' => $n->isRead(),
|
||||
'createdAt' => $n->getCreatedAt()->format('c'),
|
||||
], $notifications),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/notifications/read', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function markRead(NotificationRepository $notificationRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$notificationRepository->markAllReadForUser($user);
|
||||
|
||||
return new Response('', Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\NotificationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
|
||||
class Notification
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column(name: 'is_read')]
|
||||
private bool $read = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function setMessage(string $message): static
|
||||
{
|
||||
$this->message = $message;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->read;
|
||||
}
|
||||
|
||||
public function setRead(bool $read): static
|
||||
{
|
||||
$this->read = $read;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,27 @@ class ImportRepository extends ServiceEntityRepository
|
||||
['id' => $import->getId()]
|
||||
);
|
||||
}
|
||||
|
||||
public function findLatestForUser(\App\Entity\User $user): ?Import
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->andWhere('i.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('i.createdAt', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function hasActiveImport(\App\Entity\User $user): bool
|
||||
{
|
||||
return (int) $this->createQueryBuilder('i')
|
||||
->select('COUNT(i.id)')
|
||||
->andWhere('i.user = :user')
|
||||
->andWhere('i.status IN (:statuses)')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('statuses', [Import::STATUS_PENDING, Import::STATUS_PROCESSING])
|
||||
->getQuery()
|
||||
->getSingleScalarResult() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Notification>
|
||||
*/
|
||||
class NotificationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Notification::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Notification[]
|
||||
*/
|
||||
public function findRecentForUser(User $user, int $limit = 20): array
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->andWhere('n.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('n.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function countUnreadForUser(User $user): int
|
||||
{
|
||||
return $this->count(['user' => $user, 'read' => false]);
|
||||
}
|
||||
|
||||
public function markAllReadForUser(User $user): void
|
||||
{
|
||||
$this->createQueryBuilder('n')
|
||||
->update()
|
||||
->set('n.read', ':true')
|
||||
->where('n.user = :user')
|
||||
->andWhere('n.read = :false')
|
||||
->setParameter('true', true)
|
||||
->setParameter('false', false)
|
||||
->setParameter('user', $user)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% if app.user %}
|
||||
<div data-controller="import-modal">
|
||||
<nav class="navbar" data-controller="notifications">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-left">
|
||||
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
|
||||
</div>
|
||||
@@ -12,33 +12,21 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{# Notifications #}
|
||||
<div class="navbar-item" data-controller="dropdown">
|
||||
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span class="badge" data-notifications-target="badge" hidden></span>
|
||||
</button>
|
||||
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
|
||||
<div class="dropdown-header">Notifications</div>
|
||||
<div data-notifications-target="list" class="notifications-list">
|
||||
<p class="dropdown-empty">Aucune notification</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# User menu #}
|
||||
<div class="navbar-item" data-controller="dropdown">
|
||||
<div class="navbar-item" data-controller="dropdown import-status">
|
||||
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<span class="badge" data-import-status-target="badge" hidden></span>
|
||||
</button>
|
||||
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
|
||||
<button class="dropdown-item" data-action="click->import-modal#open">Importer ses films</button>
|
||||
<div class="import-status-item" data-import-status-target="item">
|
||||
<button class="dropdown-item" data-action="click->import-modal#open" data-import-status-target="importBtn">
|
||||
Importer ses films
|
||||
</button>
|
||||
</div>
|
||||
<a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user