336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link } from 'react-router-dom';
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useCall } from '../context/CallContext';
|
|
import { useConversationsContext } from '../context/ConversationsContext';
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { ringtone } from '../lib/ringtone';
|
|
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
|
|
import { useCallPresence } from '../lib/useCallPresence';
|
|
import {
|
|
MicIcon,
|
|
MicOffIcon,
|
|
PhoneIcon,
|
|
PhoneOffIcon,
|
|
SpinnerIcon,
|
|
XIcon,
|
|
} from './icons';
|
|
|
|
// Shell-level mount. Handles ringtones + the IncomingCallToast.
|
|
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
|
|
// users can keep browsing/typing while a call is live.
|
|
export function CallUI() {
|
|
const { state } = useCall();
|
|
|
|
useEffect(() => {
|
|
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
|
else if (state.kind === 'incoming') ringtone.start('incoming');
|
|
else ringtone.stop();
|
|
}, [state.kind]);
|
|
|
|
useEffect(() => {
|
|
return () => ringtone.stop();
|
|
}, []);
|
|
|
|
return <IncomingCallToast />;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function IncomingCallToast() {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
|
const { friendships } = useFriendshipsContext();
|
|
const { conversations } = useConversationsContext();
|
|
|
|
if (state.kind !== 'incoming') return null;
|
|
|
|
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
|
const callerName =
|
|
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
|
|
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
|
'?';
|
|
const isGroup = conv?.type === 'group';
|
|
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
|
const letter = (isGroup ? groupName ?? callerName : callerName)
|
|
.trim()
|
|
.charAt(0)
|
|
.toUpperCase() || '?';
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="false"
|
|
aria-label={t('app:call.incoming_title')}
|
|
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
|
|
>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
|
{t('app:call.incoming_title')}
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
|
|
{letter}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-display text-base font-semibold text-white">
|
|
{isGroup ? groupName : callerName}
|
|
</p>
|
|
<p className="truncate text-xs text-neutral-400">
|
|
{isGroup
|
|
? t('app:call.incoming_group_from', {
|
|
name: callerName,
|
|
defaultValue: callerName + ' ruft Gruppe',
|
|
})
|
|
: t('app:call.incoming_from', { name: callerName })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={rejectIncoming}
|
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
|
>
|
|
<PhoneOffIcon className="h-4 w-4" />
|
|
<span>{t('app:call.decline')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void acceptIncoming()}
|
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
|
>
|
|
<PhoneIcon className="h-4 w-4" />
|
|
<span>{t('app:call.accept')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
|
|
// the app while a call is active / connecting / ringing.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function CallBar() {
|
|
const { state, lastCallConversationId } = useCall();
|
|
const { conversations } = useConversationsContext();
|
|
const { session } = useAuth();
|
|
const myId = session?.user.id ?? null;
|
|
|
|
// Aggregate observer across every conversation the user is in so a
|
|
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
|
|
// peer is in a call — not just calls I previously joined.
|
|
const convIds = conversations.map((c) => c.id);
|
|
const anyActive = useAnyActiveCall(convIds, myId);
|
|
|
|
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
|
|
return <ActiveCallBar />;
|
|
}
|
|
// Prefer the conversation I just left, fall back to any other live call.
|
|
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
|
|
if (rejoinId) {
|
|
return <RejoinCallBar conversationId={rejoinId} />;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ActiveCallBar() {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, isMuted, hangup, toggleMute } = useCall();
|
|
const { conversations } = useConversationsContext();
|
|
|
|
const [, forceTick] = useState(0);
|
|
useEffect(() => {
|
|
if (state.kind !== 'connected') return;
|
|
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
|
|
return () => window.clearInterval(id);
|
|
}, [state.kind]);
|
|
|
|
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
|
|
return null;
|
|
}
|
|
|
|
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
|
const title =
|
|
conv?.type === 'group'
|
|
? conv.name ?? '—'
|
|
: conv?.peer?.displayName ?? '—';
|
|
|
|
const statusLabel =
|
|
state.kind === 'outgoing'
|
|
? t('app:call.outgoing_ringing')
|
|
: state.kind === 'connecting'
|
|
? t('app:call.connecting')
|
|
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
|
|
|
|
const elapsed =
|
|
state.kind === 'connected'
|
|
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
|
|
: null;
|
|
|
|
return (
|
|
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
|
<Link
|
|
to={'/chats/' + state.conversationId}
|
|
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
|
aria-label={statusLabel}
|
|
>
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
|
{state.kind === 'connecting' ? (
|
|
<SpinnerIcon className="h-4 w-4" />
|
|
) : (
|
|
<SignalIcon className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
|
|
<p className="truncate text-xs text-neutral-400">
|
|
{title}
|
|
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
|
|
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
|
<IconTile
|
|
onClick={toggleMute}
|
|
disabled={state.kind !== 'connected'}
|
|
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
|
tone={isMuted ? 'amber' : 'neutral'}
|
|
>
|
|
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
|
|
</IconTile>
|
|
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
|
|
<PhoneOffIcon className="h-4 w-4" />
|
|
</IconTile>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RejoinCallBar({ conversationId }: { conversationId: string }) {
|
|
const { t } = useTranslation(['app', 'common']);
|
|
const { joinActiveCall, dismissLastCall } = useCall();
|
|
const { conversations } = useConversationsContext();
|
|
const { session } = useAuth();
|
|
const active = useCallPresence(conversationId);
|
|
|
|
const myId = session?.user.id;
|
|
const others = active.filter((u) => u !== myId);
|
|
|
|
// Presence polling returns [] on the first tick before it syncs, so we can't
|
|
// treat an initial empty list as "room is empty". Only dismiss after we've
|
|
// actually seen peers and then watched them leave — and even then confirm
|
|
// the empty state for a grace period, since presence_diff events can
|
|
// arrive slightly out of order.
|
|
const [visible, setVisible] = useState(false);
|
|
useEffect(() => {
|
|
if (others.length > 0) {
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
if (!visible) return;
|
|
const id = window.setTimeout(() => {
|
|
setVisible(false);
|
|
dismissLastCall();
|
|
}, 2500);
|
|
return () => window.clearTimeout(id);
|
|
}, [others.length, visible, dismissLastCall]);
|
|
|
|
if (!visible) return null;
|
|
|
|
const conv = conversations.find((c) => c.id === conversationId) ?? null;
|
|
const title =
|
|
conv?.type === 'group'
|
|
? conv.name ?? '—'
|
|
: conv?.peer?.displayName ?? '—';
|
|
|
|
return (
|
|
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
|
<Link
|
|
to={'/chats/' + conversationId}
|
|
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
|
>
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
|
<SignalIcon className="h-4 w-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-emerald-300">
|
|
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
|
|
</p>
|
|
<p className="truncate text-xs text-neutral-400">
|
|
{title} · {others.length}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
|
<IconTile
|
|
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
|
label={t('app:call.join')}
|
|
tone="emerald"
|
|
>
|
|
<PhoneIcon className="h-4 w-4" />
|
|
</IconTile>
|
|
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
|
|
<XIcon className="h-4 w-4" />
|
|
</IconTile>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface IconTileProps {
|
|
onClick: () => void;
|
|
label: string;
|
|
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
|
disabled?: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
|
|
const toneClass =
|
|
tone === 'rose'
|
|
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
|
|
: tone === 'amber'
|
|
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
|
|
: tone === 'emerald'
|
|
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
|
|
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
className={
|
|
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
|
toneClass
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SignalIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
|
|
<path d="M5 12v0" />
|
|
<path d="M9 9v6" />
|
|
<path d="M13 6v12" />
|
|
<path d="M17 9v6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function formatElapsed(ms: number): string {
|
|
const total = Math.max(0, Math.floor(ms / 1000));
|
|
const mm = Math.floor(total / 60).toString().padStart(2, '0');
|
|
const ss = (total % 60).toString().padStart(2, '0');
|
|
return mm + ':' + ss;
|
|
}
|