feat(chat): custom right-click context menus
Override the native browser menu with resource-specific menus: - guild (rail icon): open, copy node id/name, add guild - channel: open, join/leave, close, copy id/name, create channel - message: copy text/id, mention author, delete (soft) - user (member row): mention, copy id/name, move-to-bypass (discuss/work in-channel only), settings (self) - blank areas (rail/channels/messages/members/root): scoped create/refresh actions Menu closes on click/Escape/scroll/resize; positioned within the viewport; the most specific target wins via stopPropagation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -806,3 +806,61 @@ button {
|
|||||||
.modal-actions .btn {
|
.modal-actions .btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- right-click context menu ---- */
|
||||||
|
.ctx-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 4px 8px 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 4px 2px;
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-item:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-item.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-item.danger:hover {
|
||||||
|
background: rgba(242, 63, 66, 0.14);
|
||||||
|
}
|
||||||
|
.ctx-menu .ctx-item:disabled {
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { io, type Socket } from 'socket.io-client'
|
import { io, type Socket } from 'socket.io-client'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import { useAuth } from '../auth/auth-context'
|
import { useAuth } from '../auth/auth-context'
|
||||||
import { renderMarkdown } from '../lib/markdown'
|
import { renderMarkdown } from '../lib/markdown'
|
||||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||||
@@ -37,6 +37,11 @@ type XType = (typeof X_TYPES)[number]
|
|||||||
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
|
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
|
||||||
type MemberItem = { userId: string; email: string; name: string; status: string }
|
type MemberItem = { userId: string; email: string; name: string; status: string }
|
||||||
|
|
||||||
|
type MenuItem =
|
||||||
|
| { kind: 'sep' }
|
||||||
|
| { kind: 'item'; label: string; onClick: () => void; danger?: boolean; disabled?: boolean }
|
||||||
|
type CtxMenu = { x: number; y: number; title: string; items: MenuItem[] }
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
|
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
|
||||||
const [selectedGuildId, setSelectedGuildId] = useState('')
|
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||||
@@ -64,6 +69,40 @@ export default function ChatPage() {
|
|||||||
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
|
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||||
|
|
||||||
|
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!items.length) return
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY, title, items })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text: string) {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMention(id: string) {
|
||||||
|
setContent((c) => `${c.trim() ? c.replace(/\s*$/, '') + ' ' : ''}<@${id}> `)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctxMenu) return
|
||||||
|
const close = () => setCtxMenu(null)
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === 'Escape') close()
|
||||||
|
}
|
||||||
|
window.addEventListener('click', close)
|
||||||
|
window.addEventListener('resize', close)
|
||||||
|
window.addEventListener('scroll', close, true)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', close)
|
||||||
|
window.removeEventListener('resize', close)
|
||||||
|
window.removeEventListener('scroll', close, true)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [ctxMenu])
|
||||||
|
|
||||||
function toggleDevMode(on: boolean) {
|
function toggleDevMode(on: boolean) {
|
||||||
setDevMode(on)
|
setDevMode(on)
|
||||||
@@ -291,6 +330,31 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closeChannel(c: GuildChannel) {
|
||||||
|
if (!guild || !guildToken) return
|
||||||
|
try {
|
||||||
|
await guildApi().post(`/channels/${c.id}/close`)
|
||||||
|
await loadChannels()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to close channel')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(m: MessageItem) {
|
||||||
|
if (!guild || !guildToken || !selectedChannelId) return
|
||||||
|
try {
|
||||||
|
await guildApi().delete(`/channels/${selectedChannelId}/messages/${m.messageId}`)
|
||||||
|
// socket message.deleted will reconcile; optimistic fallback:
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((x) =>
|
||||||
|
x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to delete message')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function leaveChannel(c: GuildChannel) {
|
async function leaveChannel(c: GuildChannel) {
|
||||||
if (!guild || !guildToken) return
|
if (!guild || !guildToken) return
|
||||||
try {
|
try {
|
||||||
@@ -382,7 +446,11 @@ export default function ChatPage() {
|
|||||||
const isBypass = bypassMemberIds.includes(m.userId)
|
const isBypass = bypassMemberIds.includes(m.userId)
|
||||||
const showBypassUi = inChannel && rotationChannel
|
const showBypassUi = inChannel && rotationChannel
|
||||||
return (
|
return (
|
||||||
<div key={m.userId} className="member-row">
|
<div
|
||||||
|
key={m.userId}
|
||||||
|
className="member-row"
|
||||||
|
onContextMenu={(e) => openCtx(e, label, userMenu(m, inChannel))}
|
||||||
|
>
|
||||||
<div className="avatar dot">{initials(label)}</div>
|
<div className="avatar dot">{initials(label)}</div>
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<span className="nm">{label}</span>
|
<span className="nm">{label}</span>
|
||||||
@@ -402,15 +470,182 @@ export default function ChatPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- right-click context menus (per resource) ----
|
||||||
|
function guildMenu(g: { nodeId: string; name: string }): MenuItem[] {
|
||||||
|
return [
|
||||||
|
{ kind: 'item', label: 'Open', onClick: () => setSelectedGuildId(g.nodeId) },
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{ kind: 'item', label: 'Copy node ID', onClick: () => copyText(g.nodeId) },
|
||||||
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(g.name) },
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelMenu(c: GuildChannel): MenuItem[] {
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{ kind: 'item', label: 'Open', onClick: () => setSelectedChannelId(c.id) },
|
||||||
|
]
|
||||||
|
if (c.isMember)
|
||||||
|
items.push({ kind: 'item', label: 'Leave channel', danger: true, onClick: () => leaveChannel(c) })
|
||||||
|
else if (c.isPublic)
|
||||||
|
items.push({ kind: 'item', label: 'Join channel', onClick: () => joinChannel(c) })
|
||||||
|
if (!c.closed)
|
||||||
|
items.push({ kind: 'item', label: 'Close channel', danger: true, onClick: () => closeChannel(c) })
|
||||||
|
items.push(
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{ kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) },
|
||||||
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(c.name) },
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{ kind: 'item', label: 'Create channel…', onClick: openCreateChannel },
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageMenu(m: MessageItem): MenuItem[] {
|
||||||
|
const isGuild = m.authorUserId === 'guild'
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{ kind: 'item', label: 'Copy text', onClick: () => copyText(m.content) },
|
||||||
|
{ kind: 'item', label: 'Copy message ID', onClick: () => copyText(m.messageId) },
|
||||||
|
]
|
||||||
|
if (m.authorUserId && !isGuild)
|
||||||
|
items.push({
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Mention author',
|
||||||
|
onClick: () => insertMention(m.authorUserId as string),
|
||||||
|
})
|
||||||
|
items.push(
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Delete message',
|
||||||
|
danger: true,
|
||||||
|
disabled: !!m.isDeleted || isGuild,
|
||||||
|
onClick: () => deleteMessage(m),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMenu(
|
||||||
|
m: { userId: string; name?: string; email?: string },
|
||||||
|
inChannel: boolean,
|
||||||
|
): MenuItem[] {
|
||||||
|
const isSelf = m.userId === session?.user.id
|
||||||
|
const label = m.name || m.email || m.userId.slice(0, 8)
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{ kind: 'item', label: 'Mention', onClick: () => insertMention(m.userId) },
|
||||||
|
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
|
||||||
|
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) },
|
||||||
|
]
|
||||||
|
if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
|
||||||
|
items.push(
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{ kind: 'item', label: 'Move to bypass', onClick: () => moveToBypass(m.userId) },
|
||||||
|
)
|
||||||
|
if (isSelf)
|
||||||
|
items.push(
|
||||||
|
{ kind: 'sep' },
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Settings…',
|
||||||
|
onClick: () => {
|
||||||
|
setSettingsName(session?.user.name ?? '')
|
||||||
|
setShowSettingsModal(true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankMenu(scope: 'guilds' | 'channels' | 'messages' | 'members' | 'root'): MenuItem[] {
|
||||||
|
if (scope === 'guilds')
|
||||||
|
return [{ kind: 'item', label: 'Add guild…', onClick: () => setShowAddGuildModal(true) }]
|
||||||
|
if (scope === 'channels')
|
||||||
|
return [
|
||||||
|
{ kind: 'item', label: 'Create channel…', disabled: !guild, onClick: openCreateChannel },
|
||||||
|
{ kind: 'item', label: 'Refresh channels', onClick: () => void loadChannels() },
|
||||||
|
]
|
||||||
|
if (scope === 'messages')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Refresh messages',
|
||||||
|
disabled: !selectedChannelId,
|
||||||
|
onClick: () => void pullMessages(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (scope === 'members')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Refresh members',
|
||||||
|
onClick: () => {
|
||||||
|
void loadMembers()
|
||||||
|
void loadChannelMembers()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: 'Refresh',
|
||||||
|
onClick: () => {
|
||||||
|
void loadChannels()
|
||||||
|
void loadMembers()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCtxMenu() {
|
||||||
|
if (!ctxMenu) return null
|
||||||
|
const MENU_W = 220
|
||||||
|
const estH =
|
||||||
|
36 +
|
||||||
|
ctxMenu.items.reduce((a, it) => a + (it.kind === 'sep' ? 9 : 32), 0)
|
||||||
|
const x = Math.max(6, Math.min(ctxMenu.x, window.innerWidth - MENU_W - 8))
|
||||||
|
const y = Math.max(6, Math.min(ctxMenu.y, window.innerHeight - estH - 8))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ctx-menu"
|
||||||
|
style={{ left: x, top: y, width: MENU_W }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="ctx-title">{ctxMenu.title}</div>
|
||||||
|
{ctxMenu.items.map((it, i) =>
|
||||||
|
it.kind === 'sep' ? (
|
||||||
|
<div key={i} className="ctx-sep" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`ctx-item ${it.danger ? 'danger' : ''}`}
|
||||||
|
disabled={it.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (it.disabled) return
|
||||||
|
setCtxMenu(null)
|
||||||
|
it.onClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dc-shell">
|
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
||||||
<nav className="dc-rail">
|
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
||||||
{guilds.map((g) => (
|
{guilds.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.nodeId}
|
key={g.nodeId}
|
||||||
className={`rail-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`}
|
className={`rail-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`}
|
||||||
title={g.name}
|
title={g.name}
|
||||||
onClick={() => setSelectedGuildId(g.nodeId)}
|
onClick={() => setSelectedGuildId(g.nodeId)}
|
||||||
|
onContextMenu={(e) => openCtx(e, g.name, guildMenu(g))}
|
||||||
>
|
>
|
||||||
{initials(g.name)}
|
{initials(g.name)}
|
||||||
</button>
|
</button>
|
||||||
@@ -423,7 +658,10 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
<aside className="dc-sidebar">
|
<aside className="dc-sidebar">
|
||||||
<div className="dc-sidebar-head">{guild?.name ?? 'No guild selected'}</div>
|
<div className="dc-sidebar-head">{guild?.name ?? 'No guild selected'}</div>
|
||||||
<div className="dc-sidebar-scroll">
|
<div
|
||||||
|
className="dc-sidebar-scroll"
|
||||||
|
onContextMenu={(e) => openCtx(e, 'Channels', blankMenu('channels'))}
|
||||||
|
>
|
||||||
<div className="dc-section-label">
|
<div className="dc-section-label">
|
||||||
<span>Channels</span>
|
<span>Channels</span>
|
||||||
<button title="Create channel" onClick={openCreateChannel}>
|
<button title="Create channel" onClick={openCreateChannel}>
|
||||||
@@ -436,6 +674,7 @@ export default function ChatPage() {
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
||||||
onClick={() => setSelectedChannelId(c.id)}
|
onClick={() => setSelectedChannelId(c.id)}
|
||||||
|
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
|
||||||
>
|
>
|
||||||
<span className="hash">#</span>
|
<span className="hash">#</span>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||||
@@ -499,7 +738,10 @@ export default function ChatPage() {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="dc-messages">
|
<div
|
||||||
|
className="dc-messages"
|
||||||
|
onContextMenu={(e) => openCtx(e, 'Messages', blankMenu('messages'))}
|
||||||
|
>
|
||||||
{loading ? <p className="muted" style={{ padding: 8 }}>Loading…</p> : null}
|
{loading ? <p className="muted" style={{ padding: 8 }}>Loading…</p> : null}
|
||||||
{!loading && !messages.length ? (
|
{!loading && !messages.length ? (
|
||||||
<div className="dc-empty-center">
|
<div className="dc-empty-center">
|
||||||
@@ -509,7 +751,11 @@ export default function ChatPage() {
|
|||||||
{messages
|
{messages
|
||||||
.filter((m) => devMode || m.authorUserId !== 'guild')
|
.filter((m) => devMode || m.authorUserId !== 'guild')
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
|
<div
|
||||||
|
key={m.messageId}
|
||||||
|
className={`msg ${m.isDeleted ? 'deleted' : ''}`}
|
||||||
|
onContextMenu={(e) => openCtx(e, authorLabel(m.authorUserId), messageMenu(m))}
|
||||||
|
>
|
||||||
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
|
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
@@ -569,7 +815,10 @@ export default function ChatPage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{showMembers ? (
|
{showMembers ? (
|
||||||
<aside className="dc-members">
|
<aside
|
||||||
|
className="dc-members"
|
||||||
|
onContextMenu={(e) => openCtx(e, 'Members', blankMenu('members'))}
|
||||||
|
>
|
||||||
{currentChannel && !currentChannel.isPublic ? (
|
{currentChannel && !currentChannel.isPublic ? (
|
||||||
<>
|
<>
|
||||||
<div className="dc-section-label">
|
<div className="dc-section-label">
|
||||||
@@ -784,6 +1033,8 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{renderCtxMenu()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user