Files
im_webapp/web/src/features/messages/MessageContextMenu.tsx
T
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00

116 lines
4.3 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import type { MessageAction } from '@/lib/messages/actions';
interface MenuItem {
action: MessageAction;
label: string;
icon: string;
danger?: boolean;
}
const ITEMS: Record<MessageAction, MenuItem> = {
quote: { action: 'quote', label: '引用', icon: 'format_quote' },
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' },
forward: { action: 'forward', label: '转发', icon: 'reply' },
recall: { action: 'recall', label: '撤回', icon: 'undo' },
select: { action: 'select', label: '多选', icon: 'select_all' },
delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true },
};
interface MessageContextMenuProps {
x: number;
y: number;
actions: MessageAction[];
/** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */
recallRemainingSeconds?: number | null;
onAction: (action: MessageAction) => void;
onClose: () => void;
}
function formatRecallCountdown(seconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} 内`;
}
/** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */
export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAction, onClose }: MessageContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
useEffect(() => {
const handler = (e: KeyboardEvent | MouseEvent | Event) => {
if (e instanceof KeyboardEvent && e.key !== 'Escape') return;
onClose();
};
window.addEventListener('click', handler);
window.addEventListener('scroll', handler, true);
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('click', handler);
window.removeEventListener('scroll', handler, true);
window.removeEventListener('keydown', handler);
};
}, [onClose]);
// Right-clicking near the window's right/bottom edge would otherwise render this partially
// off-screen -- clamp against the menu's actual measured size once it's in the DOM.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8);
setPos({ left: Math.max(8, left), top: Math.max(8, top) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [x, y]);
const leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
return (
<div
ref={ref}
style={pos}
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
onClick={(e) => e.stopPropagation()}
>
{leading.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm text-text-secondary hover:bg-surface-rail"
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
</button>
);
})}
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
<button
key={action}
type="button"
onClick={() => onAction(action)}
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm hover:bg-surface-rail ${
item.danger ? 'text-danger-text' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
{item.label}
{action === 'recall' && typeof recallRemainingSeconds === 'number' ? (
<span className="ml-auto text-xs text-text-tertiary-3">{formatRecallCountdown(recallRemainingSeconds)}</span>
) : null}
</button>
);
})}
</div>
);
}