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 {
|
||||
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 { 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 { renderMarkdown } from '../lib/markdown'
|
||||
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 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() {
|
||||
const { session, logout, ensureFreshToken, refreshGuilds, updateName } = useAuth()
|
||||
const [selectedGuildId, setSelectedGuildId] = useState('')
|
||||
@@ -64,6 +69,40 @@ export default function ChatPage() {
|
||||
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
|
||||
const [loading, setLoading] = useState(false)
|
||||
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) {
|
||||
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) {
|
||||
if (!guild || !guildToken) return
|
||||
try {
|
||||
@@ -382,7 +446,11 @@ export default function ChatPage() {
|
||||
const isBypass = bypassMemberIds.includes(m.userId)
|
||||
const showBypassUi = inChannel && rotationChannel
|
||||
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 style={{ minWidth: 0, flex: 1 }}>
|
||||
<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 (
|
||||
<div className="dc-shell">
|
||||
<nav className="dc-rail">
|
||||
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
||||
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
||||
{guilds.map((g) => (
|
||||
<button
|
||||
key={g.nodeId}
|
||||
className={`rail-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`}
|
||||
title={g.name}
|
||||
onClick={() => setSelectedGuildId(g.nodeId)}
|
||||
onContextMenu={(e) => openCtx(e, g.name, guildMenu(g))}
|
||||
>
|
||||
{initials(g.name)}
|
||||
</button>
|
||||
@@ -423,7 +658,10 @@ export default function ChatPage() {
|
||||
|
||||
<aside className="dc-sidebar">
|
||||
<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">
|
||||
<span>Channels</span>
|
||||
<button title="Create channel" onClick={openCreateChannel}>
|
||||
@@ -436,6 +674,7 @@ export default function ChatPage() {
|
||||
key={c.id}
|
||||
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedChannelId(c.id)}
|
||||
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
|
||||
>
|
||||
<span className="hash">#</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||
@@ -499,7 +738,10 @@ export default function ChatPage() {
|
||||
</button>
|
||||
</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 && !messages.length ? (
|
||||
<div className="dc-empty-center">
|
||||
@@ -509,7 +751,11 @@ export default function ChatPage() {
|
||||
{messages
|
||||
.filter((m) => devMode || m.authorUserId !== 'guild')
|
||||
.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="body">
|
||||
<div className="meta">
|
||||
@@ -569,7 +815,10 @@ export default function ChatPage() {
|
||||
</main>
|
||||
|
||||
{showMembers ? (
|
||||
<aside className="dc-members">
|
||||
<aside
|
||||
className="dc-members"
|
||||
onContextMenu={(e) => openCtx(e, 'Members', blankMenu('members'))}
|
||||
>
|
||||
{currentChannel && !currentChannel.isPublic ? (
|
||||
<>
|
||||
<div className="dc-section-label">
|
||||
@@ -784,6 +1033,8 @@ export default function ChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderCtxMenu()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user