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:
h z
2026-05-15 19:35:57 +01:00
parent 372805c9fa
commit f063807089
2 changed files with 317 additions and 8 deletions

View File

@@ -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;
}

View File

@@ -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="dc-shell">
<nav className="dc-rail">
<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" 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>
)
}