import { muteDurationToIso, setConversationArchived, setConversationMutedUntil, } from '@chat-app/shared/chat'; import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { supabase } from '../lib/supabase'; import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons'; interface Props { conversationId: string; archived: boolean; mutedUntil: string | null; } interface MuteOption { key: string; labelKey: string; labelDefault: string; minutes: number | null; } // Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted // until that moment; 100y is indistinguishable from "forever" at the UX level // without requiring a dedicated `bool muted_forever` column. const FOREVER_MINUTES = 100 * 365 * 24 * 60; const MUTE_OPTIONS: MuteOption[] = [ { key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 }, { key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 }, { key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 }, { key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 }, { key: 'forever', labelKey: 'app:chats.mute_forever', labelDefault: 'Bis auf Weiteres', minutes: FOREVER_MINUTES, }, ]; interface MenuPos { top: number; left: number; } // Per-conversation row context-menu. Renders via portal so the submenu can // escape the sidebar's `overflow-y-auto` clipping context. Position is // computed from the trigger's bounding rect — menu anchors right-aligned // under the trigger so it doesn't push off-screen on narrow windows. export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) { const { t } = useTranslation(['app']); const [open, setOpen] = useState(false); const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null); const [menuPos, setMenuPos] = useState(null); const [submenuPos, setSubmenuPos] = useState(null); const triggerRef = useRef(null); const menuRef = useRef(null); const muteItemRef = useRef(null); useEffect(() => { if (!open) return; function onDocClick(e: MouseEvent) { const target = e.target as Node; if (triggerRef.current?.contains(target)) return; if (menuRef.current?.contains(target)) return; setOpen(false); setSubmenuOpen(null); } function onEsc(e: KeyboardEvent) { if (e.key === 'Escape') { setOpen(false); setSubmenuOpen(null); } } document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onEsc); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onEsc); }; }, [open]); useEffect(() => { if (!open) { setMenuPos(null); setSubmenuPos(null); return; } const rect = triggerRef.current?.getBoundingClientRect(); if (!rect) return; // Anchor: right edge aligns with trigger's right edge, menu hangs below. const menuWidth = 208; setMenuPos({ top: rect.bottom + 4, left: Math.max(8, rect.right - menuWidth), }); }, [open]); useEffect(() => { if (submenuOpen !== 'mute') { setSubmenuPos(null); return; } const rect = muteItemRef.current?.getBoundingClientRect(); if (!rect) return; const submenuWidth = 192; const viewportWidth = window.innerWidth; // Prefer right of the item. Flip to left when it would overflow viewport. const wantLeft = rect.right + 4; const flip = wantLeft + submenuWidth > viewportWidth - 8; setSubmenuPos({ top: rect.top, left: flip ? rect.left - submenuWidth - 4 : wantLeft, }); }, [submenuOpen]); const isMuted = mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now(); const handleArchive = useCallback( async (next: boolean) => { setOpen(false); try { await setConversationArchived(supabase, conversationId, next); } catch (err: unknown) { console.error('archive toggle failed', err); } }, [conversationId], ); const handleMute = useCallback( async (minutes: number | null) => { setOpen(false); setSubmenuOpen(null); try { await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes)); } catch (err: unknown) { console.error('mute toggle failed', err); } }, [conversationId], ); return ( <> {open && menuPos && createPortal(
} label={ archived ? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' }) : t('app:chats.archive', { defaultValue: 'Archivieren' }) } onClick={() => void handleArchive(!archived)} /> : } label={ isMuted ? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' }) : t('app:chats.mute', { defaultValue: 'Stummschalten' }) } onClick={() => { if (isMuted) void handleMute(null); else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute')); }} hasSubmenu={!isMuted} />
, document.body, )} {open && submenuOpen === 'mute' && submenuPos && createPortal(
{MUTE_OPTIONS.map((opt) => ( void handleMute(opt.minutes)} /> ))}
, document.body, )} ); } interface MenuItemProps { icon?: React.ReactNode; label: string; onClick: () => void; hasSubmenu?: boolean; } // React 18 requires forwardRef for function components to receive refs — // without it the `ref` prop is stripped before reaching the component and // measurement-dependent submenus never position. const MenuItem = forwardRef( ({ icon, label, onClick, hasSubmenu }, ref) => ( ), ); MenuItem.displayName = 'MenuItem';