import type { ConversationSummary } from '@chat-app/shared/chat'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useCallPresence } from '../lib/useCallPresence'; import { Avatar } from './Avatar'; import { PhoneIcon, SpinnerIcon } from './icons'; interface Props { conversation: ConversationSummary; } const MAX_AVATARS = 5; /** * Discord-style persistent voice-channel band rendered at the top of the * message surface. Always visible for groups so any member can pop in * without an invite-ring; for 1:1 conversations only when someone's already * in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall * path which sends no invite signals. */ export function VoiceChannelRail({ conversation }: Props) { const { t } = useTranslation(['app']); const { session } = useAuth(); const { state, joinActiveCall } = useCall(); const presentIds = useCallPresence(conversation.id); const myId = session?.user.id ?? null; const iAmIn = (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting') && state.conversationId === conversation.id; const others = presentIds.filter((u) => u !== myId); const isGroup = conversation.type === 'group'; // For groups: persistent channel feel — show even when empty. // For DMs: only when somebody is already in (matches Discord-DM behaviour). const visible = !iAmIn && (isGroup || others.length > 0); if (!visible) return null; const showAvatars = others.slice(0, MAX_AVATARS); const overflow = Math.max(0, others.length - MAX_AVATARS); const busy = state.kind !== 'idle'; const handleJoin = () => { if (busy) return; void joinActiveCall(conversation.id, 'audio'); }; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleJoin(); } }} className={ 'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + (others.length > 0 ? 'bg-emerald-500/10 hover:bg-emerald-500/15' : 'bg-surface-3/70 hover:bg-surface-3') } >
0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent') } >

{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}

{others.length === 0 ? t('app:call.voice_empty', { defaultValue: 'Niemand drin — sei der Erste.', }) : t('app:call.voice_count', { defaultValue: '{{count}} im Channel', count: others.length, })}

{showAvatars.length > 0 && (
{showAvatars.map((id) => { const member = conversation.members.find((m) => m.userId === id); return (
); })} {overflow > 0 && ( +{overflow} )}
)}
); }