4db65993d5
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel
Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
- useCallPresence: bind presence callbacks only when we own subscribe
(Supabase forbids .on() after .subscribe() on shared dedup'd channels)
- useCallPresence: never removeChannel — channel is shared with CallContext
so tearing it down on ConversationHeader unmount killed live tracking
- ActiveCallBanner: lastCallConversationId fallback so banner shows
instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall
Bump tauri version 0.5.0 -> 0.6.0
210 lines
8.2 KiB
TypeScript
210 lines
8.2 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import { useConversationsContext } from '../context/ConversationsContext';
|
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
|
import { ringtone } from '../lib/ringtone';
|
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
|
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
|
|
|
// Shell-level mount. Responsibilities:
|
|
// - Start/stop ringtones by call-state transition.
|
|
// - Toast-style incoming-call banner for conversations the user isn't in
|
|
// (clicking the toast routes to the docked IncomingCallPanel).
|
|
// - PiP widget when an active call is live but the user is looking at a
|
|
// different route.
|
|
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 />
|
|
<PipCall />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
|
|
violet: 'bg-violet-500/90 text-white',
|
|
amber: 'bg-amber-500/90 text-white',
|
|
rose: 'bg-rose-500/90 text-white',
|
|
teal: 'bg-teal-500/90 text-white',
|
|
};
|
|
|
|
function IncomingCallToast() {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
|
const { friendships } = useFriendshipsContext();
|
|
const { conversations } = useConversationsContext();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
if (state.kind !== 'incoming') return null;
|
|
|
|
// When the user is already looking at the target conversation, the docked
|
|
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
|
|
if (location.pathname === '/chats/' + state.conversationId) 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 title = isGroup ? groupName ?? callerName : callerName;
|
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
|
const color = colorKeyFor(state.fromUserId);
|
|
|
|
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 overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate('/chats/' + state.conversationId)}
|
|
className="group block w-full cursor-pointer p-5 text-left transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
|
{t('app:call.incoming_title')}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-3">
|
|
<div
|
|
className={
|
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
|
TOAST_AVATAR[color]
|
|
}
|
|
>
|
|
{letter}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
|
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
|
|
<LockIcon className="h-3 w-3" />
|
|
<span>
|
|
{isGroup
|
|
? t('app:call.incoming_group_from', {
|
|
name: callerName,
|
|
defaultValue: callerName + ' ruft Gruppe',
|
|
})
|
|
: t('app:call.incoming_from', { name: callerName })}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<div className="flex gap-2 px-5 pb-5">
|
|
<button
|
|
type="button"
|
|
onClick={rejectIncoming}
|
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
|
>
|
|
<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-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
|
>
|
|
<PhoneIcon className="h-4 w-4" />
|
|
<span>{t('app:call.accept')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PiP widget — shown when the user has an active call but is browsing
|
|
// somewhere else. Clicking expands back to the call's conversation.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function PipCall() {
|
|
const { t } = useTranslation(['app']);
|
|
const {
|
|
state,
|
|
remoteParticipants,
|
|
remoteScreenShares,
|
|
hangup,
|
|
} = useCall();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { conversations } = useConversationsContext();
|
|
|
|
const active =
|
|
state.kind === 'connected' ||
|
|
state.kind === 'connecting' ||
|
|
state.kind === 'outgoing';
|
|
if (!active) return null;
|
|
|
|
// Only render when the user is NOT currently viewing the call's
|
|
// conversation. Inside that conversation the full dock is visible already.
|
|
if (location.pathname === '/chats/' + state.conversationId) return null;
|
|
|
|
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
|
const title =
|
|
conv?.type === 'group'
|
|
? conv?.name ?? t('app:chats.new_group')
|
|
: conv?.peer?.displayName ?? '—';
|
|
const participantCount = 1 + remoteParticipants.length;
|
|
const someoneSharing = remoteScreenShares.length > 0;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
|
|
onClick={() => navigate('/chats/' + state.conversationId)}
|
|
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
|
>
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
|
|
{someoneSharing ? (
|
|
<MonitorShareIcon className="h-5 w-5 text-accent" />
|
|
) : (
|
|
<PhoneIcon className="h-5 w-5 text-accent" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13px] font-semibold text-fg">
|
|
{title} · {participantCount}
|
|
</p>
|
|
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
|
/>
|
|
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void hangup();
|
|
}}
|
|
aria-label={t('app:call.hangup')}
|
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
|
>
|
|
<PhoneOffIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|