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:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} />}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user