import type { ConversationSummary } from '@chat-app/shared/chat'; import type { PresenceState } from '@chat-app/shared/supabase'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useCallPresence } from '../lib/useCallPresence'; import type { PeerPresence } from '../lib/usePeerPresence'; import { Avatar } from './Avatar'; import { InfoIcon, PhoneIcon, SearchIcon, SpinnerIcon, UsersIcon, VideoIcon, } from './icons'; const PRESENCE_DOT: Record = { online: 'bg-emerald-500', idle: 'bg-amber-400', dnd: 'bg-rose-500', invisible: 'bg-neutral-500', offline: 'bg-neutral-400 dark:bg-neutral-600', }; interface Props { conversation: ConversationSummary | null; peerPresence: PeerPresence | null; onInfoClick?: () => void; onSearchClick?: () => void; } export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) { if (!conversation) { return
; } return ( <> ); } interface HeaderBarProps { conversation: ConversationSummary; peerPresence: PeerPresence | null; onInfoClick?: () => void; onSearchClick?: () => void; } function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) { const { t } = useTranslation(['app']); const isDm = conversation.type === 'dm'; const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?'); const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : ''; const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null); // Hide presence when peer chose invisible — reciprocal privacy. const peerState = peerPresence?.state ?? null; const showPresence = isDm && peerState && peerState !== 'invisible'; // Subtitle priority: custom status_message when online (or idle/dnd), else // the localized presence label. Offline always wins → just "Offline". const presenceLabel = (() => { if (!peerState) return ''; if (peerState === 'offline') return t('app:presence.offline'); if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) { return peerPresence.statusMessage.trim(); } return t('app:presence.' + peerState); })(); return (
{isDm ? ( ) : peerAvatar ? ( ) : (
)} {showPresence && peerState && (

{title}

{isDm ? ( <> {handle} {showPresence && ( <> · {presenceLabel} )} ) : ( <> {conversation.members.length} {t('app:nav.friends').toLowerCase()} )}

{!isDm && onInfoClick && ( )}
); } interface HeaderActionButtonProps { label: string; icon: (props: React.SVGProps) => React.JSX.Element; onClick?: () => void; disabled?: boolean; tone?: 'default' | 'accent'; } function HeaderActionButton({ label, icon: Icon, onClick, disabled, tone = 'default', }: HeaderActionButtonProps) { const toneClass = tone === 'accent' ? 'text-accent hover:bg-accent/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'; return ( ); } function CallHeaderButton({ conversationId, kind, }: { conversationId: string; kind: 'audio' | 'video'; }) { const { t } = useTranslation(['app']); const { state, startCall } = useCall(); const busy = state.kind !== 'idle'; const label = kind === 'video' ? t('app:call.start_video', { defaultValue: 'Video-Anruf' }) : t('app:call.start_audio'); const Icon = kind === 'video' ? VideoIcon : PhoneIcon; return ( void startCall(conversationId, kind)} disabled={busy} /> ); } function ActiveCallBanner({ conversationId }: { conversationId: string }) { const { t } = useTranslation(['app']); const { session } = useAuth(); const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall(); const active = useCallPresence(conversationId); const myId = session?.user.id; const iAmIn = (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') && state.conversationId === conversationId; const othersIn = active.filter((u) => u !== myId); // Fallback signal: I just left this conv with peers still inside. Covers the // brief window after hangup where presence may not have re-synced yet (the // realtime channel can churn while ConversationHeader remounts). const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId; // Once presence confirms the room is empty, drop the "just left" hint so the // banner hides cleanly instead of sticking forever. Grace window handles the // brief gap between hangup and presence re-sync so we don't flicker. useEffect(() => { if (!justLeft) return; if (othersIn.length > 0) return; // still live — keep banner const id = window.setTimeout(() => dismissLastCall(), 3000); return () => window.clearTimeout(id); }, [justLeft, othersIn.length, dismissLastCall]); if (iAmIn) return null; if (othersIn.length === 0 && !justLeft) return null; const count = Math.max(othersIn.length, justLeft ? 1 : 0); const busy = state.kind !== 'idle'; return (
{t('app:call.active_in_conv', { defaultValue: 'Active call · {{count}} in room', count, })}
); }