feat(desktop): add CallPreviewPanel — Discord-DM-style join surface
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useState } from 'react';
|
||||||
|
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, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: ConversationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TILES = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discord-DM-style call preview panel. Renders only while peers are in the
|
||||||
|
* conversation's active call and the local user is NOT in it. Provides large
|
||||||
|
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
|
||||||
|
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
|
||||||
|
* component never initiates — only joins.
|
||||||
|
*/
|
||||||
|
export function CallPreviewPanel({ conversation }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { session } = useAuth();
|
||||||
|
const { state, joinActiveCall } = useCall();
|
||||||
|
const presentIds = useCallPresence(conversation.id);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (iAmIn || others.length === 0) return null;
|
||||||
|
|
||||||
|
const visibleTiles = others.slice(0, MAX_TILES);
|
||||||
|
const overflow = Math.max(0, others.length - MAX_TILES);
|
||||||
|
const busy = state.kind !== 'idle';
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
if (busy) return;
|
||||||
|
void joinActiveCall(conversation.id, 'audio');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-line bg-surface-3/70">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-2 text-xs">
|
||||||
|
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||||
|
{t('app:call.active_in_conv', {
|
||||||
|
defaultValue: 'Laufender Anruf · {{count}} im Raum',
|
||||||
|
count: others.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
|
||||||
|
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
|
||||||
|
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
{visibleTiles.map((id) => {
|
||||||
|
const member = conversation.members.find((m) => m.userId === id);
|
||||||
|
const name = member?.profile?.displayName ?? '?';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
title={name}
|
||||||
|
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
|
||||||
|
>
|
||||||
|
<div className="h-16 w-16 overflow-hidden rounded-full">
|
||||||
|
<Avatar
|
||||||
|
url={member?.profile?.avatarUrl ?? null}
|
||||||
|
displayName={name}
|
||||||
|
className="h-full w-full text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{overflow > 0 && (
|
||||||
|
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
|
||||||
|
<span className="text-xl font-semibold">+{overflow}</span>
|
||||||
|
<span className="text-xs">weitere</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm 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-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PhoneIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user