825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
397 lines
17 KiB
TypeScript
397 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } 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 { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
|
import { Avatar } from './Avatar';
|
|
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.
|
|
// - Top-center pending-incoming toast for Discord-style second-call ringer.
|
|
export function CallUI() {
|
|
const { state, pendingIncoming } = useCall();
|
|
const { profile } = useAuth();
|
|
const dnd = profile?.presenceState === 'dnd';
|
|
|
|
useEffect(() => {
|
|
// DND silences only the *incoming* ring — outgoing stays audible because
|
|
// the user initiated that call themselves. The incoming-call panel still
|
|
// appears visually; only the audible ring is suppressed. The pending
|
|
// second-call ringer plays the same incoming sound at the same volume:
|
|
// user explicitly asked for parity with the normal ring.
|
|
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
|
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
|
else if (pendingIncoming && !dnd) ringtone.start('incoming');
|
|
else ringtone.stop();
|
|
}, [state.kind, pendingIncoming, dnd]);
|
|
|
|
useEffect(() => {
|
|
return () => ringtone.stop();
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<IncomingCallToast />
|
|
<PendingIncomingToast />
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discord-style "second-call" ringer — pops at the top centre while we're
|
|
// already in another call. Accepting hangs up the active call (handled in
|
|
// CallContext.acceptPendingIncoming) then routes to the new conversation.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function PendingIncomingToast() {
|
|
const { t } = useTranslation(['app']);
|
|
const { pendingIncoming, acceptPendingIncoming, rejectPendingIncoming } = useCall();
|
|
const { friendships } = useFriendshipsContext();
|
|
const { conversations } = useConversationsContext();
|
|
const navigate = useNavigate();
|
|
|
|
if (!pendingIncoming) return null;
|
|
|
|
const conv = conversations.find((c) => c.id === pendingIncoming.conversationId) ?? null;
|
|
const callerName =
|
|
conv?.members.find((m) => m.userId === pendingIncoming.fromUserId)?.profile?.displayName ??
|
|
friendships.find((f) => f.peer.userId === pendingIncoming.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(pendingIncoming.fromUserId);
|
|
const targetConversationId = pendingIncoming.conversationId;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="false"
|
|
aria-label={t('app:call.incoming_title')}
|
|
className="pointer-events-none fixed inset-x-0 top-5 z-[70] flex justify-center px-4"
|
|
>
|
|
<div className="pointer-events-auto w-full max-w-[420px] animate-slide-down overflow-hidden rounded-2xl border border-emerald-500/50 bg-surface-3/95 shadow-call-card-dark backdrop-blur-md">
|
|
<div className="flex items-center gap-3 px-5 pt-4">
|
|
<div
|
|
className={
|
|
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
|
TOAST_AVATAR[color]
|
|
}
|
|
>
|
|
{letter}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-500 dark:text-emerald-400">
|
|
{t('app:call.incoming_while_busy', {
|
|
defaultValue: 'Anderer Anruf eingehend',
|
|
})}
|
|
</p>
|
|
<p className="mt-0.5 truncate font-display text-base font-semibold text-fg">
|
|
{title}
|
|
</p>
|
|
<p className="truncate text-xs text-fg-muted">
|
|
{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="flex gap-2 px-5 pb-4 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={rejectPendingIncoming}
|
|
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={() => {
|
|
// Route first so the user lands on the new conversation
|
|
// before the connecting state resolves — feels snappier than
|
|
// waiting for the join to complete. acceptPendingIncoming
|
|
// tears down the old call internally.
|
|
navigate('/chats/' + targetConversationId);
|
|
void acceptPendingIncoming();
|
|
}}
|
|
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_and_switch', { defaultValue: 'Wechseln & Annehmen' })}</span>
|
|
</button>
|
|
</div>
|
|
</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,
|
|
room,
|
|
remoteParticipants,
|
|
remoteScreenShares,
|
|
hangup,
|
|
} = useCall();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { conversations } = useConversationsContext();
|
|
const { profile } = useAuth();
|
|
// Active-speakers hook drives the green ring on mini-avatars so the user
|
|
// can spot who's talking from the PiP without expanding back to the call.
|
|
const activeSpeakers = useActiveSpeakers(room);
|
|
|
|
const active =
|
|
state.kind === 'connected' ||
|
|
state.kind === 'connecting' ||
|
|
state.kind === 'reconnecting' ||
|
|
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 someoneSharing = remoteScreenShares.length > 0;
|
|
// Discord-style mini-grid: collect actual present participants (self +
|
|
// joined remotes), match with profile data from the conversation roster
|
|
// for avatars/displayName. Show up to MAX, plus a "+N" overflow chip.
|
|
const MAX_TILES = 4;
|
|
const myId = profile?.userId ?? null;
|
|
const presentIds: string[] = [];
|
|
if (myId) presentIds.push(myId);
|
|
for (const rp of remoteParticipants) {
|
|
if (rp.identity && !presentIds.includes(rp.identity)) presentIds.push(rp.identity);
|
|
}
|
|
const tileIds = presentIds.slice(0, MAX_TILES);
|
|
const overflow = Math.max(0, presentIds.length - MAX_TILES);
|
|
// Duration ticks while connected or reconnecting (LiveKit holds the room
|
|
// across reconnects, so the timer shouldn't reset on a wobble). Absent
|
|
// on outgoing/connecting where the call hasn't started yet.
|
|
const startedAt =
|
|
state.kind === 'connected' || state.kind === 'reconnecting'
|
|
? state.startedAt
|
|
: null;
|
|
|
|
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-[300px] motion-safe:animate-slide-in-call cursor-pointer flex-col gap-2 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-2">
|
|
{someoneSharing ? (
|
|
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
|
) : (
|
|
<PhoneIcon className="h-4 w-4 text-accent" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13px] font-semibold text-fg">{title}</p>
|
|
<p className="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 motion-safe:animate-live-dot"
|
|
/>
|
|
<span className="tabular-nums">
|
|
{startedAt
|
|
? <PipDuration startedAt={startedAt} />
|
|
: 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>
|
|
<div className="flex items-center gap-1.5">
|
|
{tileIds.map((id) => {
|
|
const member = conv?.members.find((m) => m.userId === id);
|
|
const isMe = id === myId;
|
|
const name = isMe
|
|
? profile?.displayName ?? '?'
|
|
: member?.profile?.displayName ?? '?';
|
|
const avatarUrl = isMe
|
|
? profile?.avatarUrl ?? null
|
|
: member?.profile?.avatarUrl ?? null;
|
|
const speaking = activeSpeakers.has(id);
|
|
return (
|
|
<div
|
|
key={id}
|
|
title={name + (isMe ? ' (du)' : '')}
|
|
className={
|
|
'relative h-8 w-8 shrink-0 overflow-hidden rounded-full ring-2 transition-shadow ' +
|
|
(speaking
|
|
? 'ring-emerald-500 shadow-[0_0_0_2px_rgba(34,197,94,0.35)]'
|
|
: 'ring-line')
|
|
}
|
|
>
|
|
<Avatar url={avatarUrl} displayName={name} className="h-full w-full text-xs" />
|
|
</div>
|
|
);
|
|
})}
|
|
{overflow > 0 && (
|
|
<span className="ml-0.5 inline-flex h-8 min-w-[2rem] items-center justify-center rounded-full bg-surface-2 px-2 text-[11px] font-semibold text-fg-muted">
|
|
+{overflow}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
|
|
// deliberately — the two widgets have different typography + tabular
|
|
// contexts, and extracting a shared component would be heavier than the
|
|
// 8-line countup it replaces.
|
|
function PipDuration({ startedAt }: { startedAt: string }) {
|
|
const [, tick] = useState(0);
|
|
useEffect(() => {
|
|
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
const total = Math.max(
|
|
0,
|
|
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
|
|
);
|
|
const hh = Math.floor(total / 3600);
|
|
const mm = Math.floor((total % 3600) / 60);
|
|
const ss = total % 60;
|
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
|
|
}
|