refactor(desktop): replace VoiceChannelRail with CallPreviewPanel

Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon.
This commit is contained in:
byGalax
2026-05-15 23:59:17 +02:00
parent b55ccf899f
commit 0e05a2cd85
2 changed files with 2 additions and 143 deletions
@@ -1,141 +0,0 @@
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 (
<div
role="button"
tabIndex={0}
onClick={handleJoin}
onKeyDown={(e) => {
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')
}
>
<div
className={
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
}
>
<PhoneIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
</p>
<p className="truncate text-xs text-fg">
{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,
})}
</p>
</div>
{showAvatars.length > 0 && (
<div className="flex -space-x-2">
{showAvatars.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
return (
<div
key={id}
title={member?.profile?.displayName ?? '?'}
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
>
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={member?.profile?.displayName ?? '?'}
className="h-full w-full text-[10px]"
/>
</div>
);
})}
{overflow > 0 && (
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
+{overflow}
</span>
)}
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleJoin();
}}
disabled={busy}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PhoneIcon className="h-3.5 w-3.5" />
)}
<span>
{others.length === 0
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
: t('app:call.join', { defaultValue: 'Beitreten' })}
</span>
</button>
</div>
);
}
+2 -2
View File
@@ -23,7 +23,7 @@ import {
} from '../components/icons'; } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { VoiceChannelRail } from '../components/VoiceChannelRail'; import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
@@ -654,7 +654,7 @@ export function ConversationPage() {
{/* Discord-style persistent voice-channel rail. Always visible in groups {/* Discord-style persistent voice-channel rail. Always visible in groups
so anyone can pop in without an invite-ring; hidden in 1:1s unless so anyone can pop in without an invite-ring; hidden in 1:1s unless
someone is already waiting. Hides automatically once we're in. */} someone is already waiting. Hides automatically once we're in. */}
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />} {conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />} {incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />} {conversation && <InCallPanel conversation={conversation} />}