From f063807089094572a62637d8b7d1e640ee74c4b3 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 15 May 2026 19:35:57 +0100 Subject: [PATCH] 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) --- src/index.css | 58 +++++++++ src/pages/ChatPage.tsx | 267 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 317 insertions(+), 8 deletions(-) diff --git a/src/index.css b/src/index.css index 0059faf..8a73fb5 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 4919439..ef609e1 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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(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 ( -
+
openCtx(e, label, userMenu(m, inChannel))} + >
{initials(label)}
{label} @@ -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 ( +
e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > +
{ctxMenu.title}
+ {ctxMenu.items.map((it, i) => + it.kind === 'sep' ? ( +
+ ) : ( + + ), + )} +
+ ) + } + return ( -
-